📥Getting orders in

Send orders to Shippified through Discord-format bot URLs, raw email import and email sources, and understand how every message is parsed.

Written for
Developers feeding orders into Shippified
Applies to
All plans
AdminUpdated Sep 26, 2026

Shippified builds orders from messages. There are four ways to get them in from code:

Path

Best for

Credential

Discord-format bot URL

Checkout monitors, bots, and any script that can post a Discord webhook

The URL itself

Raw email import

Pushing single emails from your own mail tooling

API key

Email sources

Letting Shippified read a mailbox (IMAP, Gmail, Microsoft) or receive forwarded mail

API key to manage them

Direct order creation

Orders you already have as structured data

API key

The first three feed one intake pipeline. Each message is stored, parsed, and then either merged into the order with the same order number or created as a new order. Storing the message means it can be re-parsed later when your templates improve.

How a message becomes an order

  1. Normalize. A raw email is decoded into From, To, Subject, Date, HTML and plain text. A manual forward (Gmail's "Forwarded message", Apple Mail's "Begin forwarded message:", Outlook's "Original Message") is unwrapped, so From and Subject are the retailer's, not yours. A Discord payload is flattened into text plus a map of its embed fields.

  2. Check the sender (email only). If the email source has an allowedSenders list, the sender must match it, whichever way the email arrived. For an unwrapped manual forward, the quoted original sender is checked only when the forward came from the account owner's own address (the account email, the source's address, its IMAP username, or the connected Gmail or Microsoft mailbox). A forward from anyone else is checked against the forwarder's address. Refused emails are logged with outcome rejected and go no further.

  3. Skip repeats (email only). A message whose Message-ID (or, lacking one, content hash) was already ingested is a duplicate and changes nothing.

  4. Pick a shape. A shape recognises one kind of message and says which fields to pull out. Candidates are tried in order and the first whose match rules all pass wins:

    1. Discord only: the template pinned on the bot (templateId), applied without checking its rules.

    2. Your custom templates for that channel, newest first.

    3. Built-in shapes for retailer emails and known monitor bots.

    An email source with a non-empty templateIds list only considers those shapes, in that order. A custom template with no match rules never wins on its own; it only runs when a bot pins it.

  5. Extract. The shape's mappings run first; generic patterns then fill anything still missing (order number, item, total, quantity, tracking number, carrier, store).

  6. Drop non-order email. An email that matched no shape is ignored when it isn't from a known retailer domain, has no order number, can't be classified, or has a subject known to be unrelated to purchases. Discord posts and emails you import by hand are never dropped this way.

  7. Merge or create, then fire the matching webhook events. If the workspace has an updates webhook set (Shippified pings webhook in Settings → Forwarding), a status card is also posted there for a new order, or for a merge that changed the order's status or carried an update event, subject to that event type's toggle. Carrier tracking changes post the same card. Mailbox rebuilds and backfills post nothing.

Every email that creates or merges an order, or is refused (blocked sender, plan limit), is written to the intake log (GET /api/webhook-logs, channel email), which is what the dashboard's Inbox shows. Ignored mail and repeats aren't logged.

To make Shippified understand a sender it doesn't recognise, write a custom template: see the custom templates guide and the Templates API.

Discord-format bot URLs

Each bot you create gets its own intake URL. It accepts the same JSON body as Discord's "Execute Webhook" call, so you point a monitor's webhook setting at it instead of a Discord channel, or post to it from your own code.

POST https://shippified.net/api/webhooks/discord/{webhookHandle}/{botSlug}

Segment

Where to get it

webhookHandle

Your account's unguessable handle: user.webhookHandle from GET /api/auth/me. For URLs made before handles existed, your user ID also works.

botSlug

The slug of one of your bots. See Manage bots.

The dashboard shows each bot's full URL under Links → Webhooks, with Copy and Send test payload buttons.

The Links page listing four webhook bots, each with its intake URL and a Copy button

No Authorization header is used: the handle and slug together are the credential.

Anyone with the full URL can create orders in your workspace. Keep it private. If it leaks, change the bot's slug with PATCH /api/bots/:id; the old URL stops working at once.

Query strings such as Discord's ?wait=true are accepted and ignored. Only JSON bodies are read: multipart uploads with payload_json and file attachments aren't parsed.

Payload

These parts of the Discord message are read:

{
  "username": "Acme Monitor",
  "content": "Successful checkout!",
  "embeds": [
    {
      "title": "Successful Checkout",
      "description": "Walmart",
      "url": "https://www.example.com/product/12345",
      "author": { "name": "Acme Monitor" },
      "footer": { "text": "Acme Monitor v2" },
      "thumbnail": { "url": "https://cdn.example.com/p/12345.png" },
      "fields": [
        { "name": "Site", "value": "Walmart" },
        { "name": "Product", "value": "Example Console Bundle" },
        { "name": "Price", "value": "$499.99" },
        { "name": "Order", "value": "||2000123-45678||" },
        { "name": "Qty", "value": "2" }
      ]
    }
  ]
}
  • Embed fields are matched by name, ignoring case. If several embeds have the same field name, the first wins.

  • Discord formatting in values (||spoiler||, **bold**, __underline__, backticks) is removed.

  • The first https thumbnail.url or image.url becomes the order's imageUrl.

  • The whole payload is stored, so template JSONPath selectors can read any field, including ones Discord doesn't define.

Send a checkout

curl -s -X POST \
  "https://shippified.net/api/webhooks/discord/$SHIPPIFIED_WEBHOOK_HANDLE/acme-monitor" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "Acme Monitor",
    "embeds": [{
      "title": "Successful Checkout",
      "footer": { "text": "Acme Monitor v2" },
      "fields": [
        { "name": "Site", "value": "Walmart" },
        { "name": "Product", "value": "Example Console Bundle" },
        { "name": "Price", "value": "$499.99" },
        { "name": "Order", "value": "2000123-45678" }
      ]
    }]
  }'

Responses

Situation

Status

Body

Order created or merged

202

{ "order": { … }, "merged": false, "forward": { "queued": true } }

Free-plan monthly limit reached

202

{ "rejected": true, "reason": "plan_limit_reached", "cap": 100, "used": 100 }

Order already in the workspace

202

{ "duplicate": true, "orderId": "ord_…" }

Post didn't produce an order

202

{ "ignored": true }

Unknown handle

404

{ "error": "Unknown webhook URL" }

Unknown bot slug

404

{ "error": "Unknown bot webhook route for this user" }

Body over 2 MiB

413

{ "error": "Request body too large (limit 2 MiB)." }

Rate limit

429

See Rate limits.

order is the full order object after the merge or create.

A plan-limit rejection is a 202, not an error, so Discord and monitors don't retry it. Check the body for rejected: true.

The response is sent as soon as the order is saved. If the bot has an outputWebhook (or the workspace has a fallback one in settings), Shippified then posts a short summary there: up to 3 attempts, 8 seconds each, waiting 1.5 and then 4 seconds between them; 429 and 5xx answers are retried, other 4xx aren't. The result lands in the intake log rather than in the response.

Intake log

Every post that reaches a bot is logged with an outcome:

Outcome

Meaning

created

A new order was created.

merged

The post was merged into an existing order.

review

A new order came out with status issue, or forwarding to the output webhook failed. error says which (output webhook forward failed after … or fallback webhook forward failed after …).

rejected

The plan limit was reached (plan_limit_reached (used/cap)) or the post produced no order (not ingested: …).

Read one bot's history with GET /api/bots/:id/logs (newest first, ?limit= 1 to 500, default 200, returns { "logs": [ … ] }), or the whole workspace's with GET /api/webhook-logs (?limit= default 100, max 500). The dashboard shows the same under View bot activity.

Rate limits

Scope

Limit

Per client IP, all bots together

60 requests a minute

Per bot URL

60 requests a minute

The per-IP limit is checked before the URL is looked up. Both return 429 with Retry-After: 60.

No deduplication

Discord payloads have no message ID, so identical posts are not deduplicated: two identical checkouts can be two real orders. Posts carrying the same order number still merge into one order.

Manage bots

Method

Path

Purpose

GET

/api/bots

List bots (paginated).

GET

/api/bots/:id

Get one bot. 404 {"error": "Bot not found"} if missing.

POST

/api/bots

Create a bot. Returns 201 with the bot.

PATCH

/api/bots/:id

Update name, slug, avatar, templateId, templateHint, samplePayload, outputWebhook or type.

DELETE

/api/bots/:id

Delete a bot. Orders it created stay.

GET

/api/bots/:id/logs

This bot's intake log.

Body fields for POST /api/bots (all optional):

Field

Notes

name

Defaults to "New Bot".

slug

Lowercased; anything other than a-z, 0-9 and - becomes -. Defaults to one made from name. If already used in your account, -2, -3… is added. The slug is the last part of the intake URL.

templateId

A built-in webhook shape ID (from GET /api/templates) or one of your custom template IDs. Applied to every post without checking its rules. Leave it out, or PATCH it to "", for automatic selection, shown in the dashboard as Auto-detect (recommended).

outputWebhook

A Discord webhook URL that gets a short summary of each parsed order. Must be a public http(s) URL; one that resolves to a private address is refused with 400 (for example outputWebhook: url cannot point at private / loopback addresses).

type

monitor (default), dropship or generic. A label for you; the dashboard also uses it to shape its test payload. It never affects parsing.

avatar, templateHint, samplePayload

Display and reference fields; they don't affect parsing.

curl -s -X POST https://shippified.net/api/bots \
  -H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Monitor", "slug": "acme-monitor"}'

For the dashboard walkthrough, see the Discord monitor bots guide.

Import a raw email

POST /api/email/import parses one email through an email source you choose and creates a real order. Because you picked the message on purpose, it's never dropped as non-order mail: if no shape matches, it still becomes an order (status issue if nothing useful was found). To see how a message would parse without creating anything, use POST /api/templates/custom/detect.

Body field

Type

Required

Notes

emailSourceId

string

Yes

One of your email sources. Its template list and sender allow-list apply.

raw

string

Yes

The email. Full RFC 822 source (headers and MIME) gives the best results; pasted HTML or text also works.

from

string

No

A fallback sender, used only when raw has no From: header. Name <[email protected]> is fine.

jq -n --arg raw "$(cat order-confirmation.eml)" \
  '{emailSourceId: "email_m1x2y3z5", raw: $raw}' |
curl -s -X POST https://shippified.net/api/email/import \
  -H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- | jq '{order: .order.id, merged, duplicate}'

The dashboard's version is Import an email by hand on Links → Email, with a From (sender) field and a Raw email body box.

The Import an email by hand panel with a From field, a raw email body and a Run parser button

Responses

Situation

Status

Body

Order created or merged

202

The workspace snapshot (as GET /api/state) plus order and merged

Already imported (same Message-ID)

200

Snapshot plus order, merged: false, duplicate: true

emailSourceId missing or unknown

404

{"error": "Create/select an email source first"}

raw empty

400

{"error": "Paste the email (raw source, HTML or text) into `raw`."}

Sender not allowed

403

{"error": "Sender [email protected] is not in this source's allowed senders.", "blockedSender": "[email protected]"}

Source has an allow-list and no sender could be found

403

{"error": "This source has allowed senders set — include the From header, or pass `from`."}

Couldn't be read as an email order

422

{"error": "Couldn't read that email."}

Free-plan monthly limit

429

{"error": "Free plan limit reached — …", "reason": "plan_limit_reached", "cap": 100, "used": 100}

A successful import response contains the whole workspace snapshot, so it grows with the workspace. Read order, merged and duplicate and ignore the rest.

A successful import posts a status card to the workspace's updates webhook when one is set in settings and that event type is switched on, the same as mail arriving any other way (see How a message becomes an order). Imports, including refused ones, appear in the intake log with channel email.

Allowed senders

Allow-list entries can be:

Entry

Matches

[email protected]

Exactly that address.

*@example.com

Anyone at example.com or its subdomains.

example.com

Same as *@example.com: [email protected] matches too.

Comparisons ignore case. An empty list allows any sender. The check applies on every path, not just this endpoint. For a manual forward, it uses the quoted original sender only when the account owner forwarded the email (from the account email, the source's address, its IMAP username or its connected Gmail or Microsoft mailbox); anyone else forwarding in is judged by their own address, since a quoted From: line is just text.

Email sources

An email source is a mailbox Shippified reads, or a forwarding address it receives mail at. For connecting providers step by step (including Gmail and Microsoft sign-in), see Connect email.

Method

Path

Purpose

GET

/api/email-sources

List sources (paginated). Credentials are never returned.

GET

/api/email-sources/:id

Get one source.

POST

/api/email-sources

Create a source.

PATCH

/api/email-sources/:id

Update label, address, templateIds, allowedSenders or (IMAP only) imapConfig.

DELETE

/api/email-sources/:id

Delete a source.

POST

/api/email-sources/:id/poll

Check the mailbox now.

POST

/api/email-sources/:id/rebuild

Re-read the mailbox from a past date and rebuild orders.

Create a source

Body field

Notes

provider

imap, google, microsoft or forwarding. Anything else is treated as imap.

label

Defaults to "Order Inbox".

address

Display address. For forwarding the server generates it.

templateIds

Shape or template IDs to try, in order. Empty means all.

allowedSenders

Sender allow-list (see above).

imapConfig

Required for imap: { host, port (default 993), tls (default true), username, password }. The password is encrypted at rest and never returned.

  • google and microsoft sources start as needs_auth and become connected once the owner finishes the sign-in in the dashboard; the API can't complete that step.

  • forwarding sources get a generated address <slug>@shippified.net in forwardingConfig.inboundAddress (also copied to address). Mail forwarded there is ingested automatically.

curl -s -X POST https://shippified.net/api/email-sources \
  -H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider": "forwarding", "label": "Orders", "allowedSenders": ["*@target.com"]}'

IMAP errors: 400 {"error": "imapConfig.host is required."}, imapConfig.username is required. and imapConfig.password is required to connect an IMAP source.

Update a source

PATCH /api/email-sources/:id only accepts the fields listed above. status, provider tokens, the forwarding slug and sync cursors are owned by the server. For imap, omit password (or send "") to keep the stored one. Saving imapConfig on a source in auth_failed sets it back to connected and clears lastError, so the next poll tries the new credentials.

Sync fields on a source

Field

Meaning

status

connected, needs_auth or auth_failed. A poller sets auth_failed when the provider rejects the sign-in and stops polling until you reconnect (OAuth) or edit the credentials (IMAP).

lastPolledAt

When the last poll (or, for forwarding, the last message) finished.

lastError

The last poll's error; cleared by the next success.

rebuilding, lastRebuild

See Rebuild from mailbox.

forwardingConfig.pendingVerification

Forwarding only. { provider: "gmail", code?, link?, requestedBy?, receivedAt } from the last Gmail forwarding-confirmation email, so the owner can finish Gmail's setup.

Poll now

Connected mailboxes are already checked about once a minute. POST /api/email-sources/:id/poll checks right away and waits for the result (the dashboard's Sync now):

{ "ingested": 3, "duplicates": 12, "ignored": 40, "source": { "id": "email_m1x2y3z5", "lastPolledAt": "…" } }

ingested counts messages that created or merged an order, duplicates messages already seen, ignored non-order mail. Blocked senders appear in the intake log instead. error is set when the poll failed. A forwarding source returns zeros and "error": "Forwarding sources receive mail via the inbound webhook — there's nothing to poll.".

Rebuild from mailbox

POST /api/email-sources/:id/rebuild with an optional sinceDays (whole number 1 to 365, default 30) re-reads the mailbox and replaces the orders this source produced in that window. Before anything else it refuses, with 400 and nothing changed, a forwarding source or one whose status is needs_auth, auth_failed or paused. Otherwise it answers 202 {"started": true, "since": "<ISO timestamp>"} and runs in the background:

  1. Signs in and checks the mailbox can be read. If not, nothing is deleted and lastRebuild.error starts with Couldn't read the mailbox, so nothing was changed:.

  2. Deletes this source's orders received since then that came only from email and have no sale price and no hand-entered values.

  3. Reads every message since that date through the current parser (at most the 5,000 most recent, for IMAP, Gmail and Microsoft alike).

Progress shows on the source: rebuilding: true while it runs, and lastRebuild: { startedAt, finishedAt?, since, removed?, ingested?, duplicates?, ignored?, error? }. A rebuild is a history backfill, so it fires no webhook events or notifications.

curl -s -X POST https://shippified.net/api/email-sources/email_m1x2y3z5/rebuild \
  -H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sinceDays": 90}'

Error

Response

Forwarding source

400 {"error": "Forwarding sources have no mailbox to re-read."}

Source needs_auth or auth_failed

400 {"error": "This mailbox needs to be reconnected before it can be rebuilt. Reconnect it, then try again — nothing was changed."}

Source paused

400 {"error": "This mailbox is paused. Resume it, then rebuild — nothing was changed."}

sinceDays out of range

400 {"error": "sinceDays must be 1–365."}

Already running

409 {"error": "A rebuild is already running for this source."}

Rebuilding deletes and re-creates orders. Orders you edited, priced or that also came from Discord or manual entry are kept. To re-run parsing on stored messages without touching the mailbox, use re-parse instead. See the rebuild guide.

Troubleshooting

My bot posts but no order appears

Open View bot activity (or GET /api/bots/:id/logs). A rejected entry with plan_limit_reached means the free plan's monthly limit is used up; not ingested: ignored means the post produced no order, and not ingested: duplicate that the order is already in the workspace. If there's no entry at all, the URL is wrong: check the handle and slug, and look for 404 in the monitor's logs.

Orders arrive as "Unparsed order" with status issue

No shape recognised the post and generic patterns found no item. Write a custom template for the bot, pin it with templateId, and re-parse.

Import returns 403 "is not in this source's allowed senders"

The sender isn't on the source's allow-list. For a manual forward that's the quoted original sender only if the forward came from the account owner's own address; otherwise it's the forwarder. The error's blockedSender shows which address was checked. Add that address or domain, or import through a source without one.

The same checkout created two orders

Discord posts aren't deduplicated. They merge only when both carry the same order number; map orderNumber in your template so later posts merge.

Was this page helpful?