Getting orders in

AdminUpdated Sep 24, 2026

Getting orders in

Shippified creates orders from messages. There are four ways to send messages in programmatically:

Path

Best for

Auth

Discord-format webhook intake

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

The URL is the credential

Manual email import

Pushing individual raw emails from your own mail tooling

API key

Email sources

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

API key to manage

Direct order creation

Orders you already have as structured data

API key

All message-based paths feed one intake pipeline. Each message is stored, parsed into an order, and then merged into an existing order that has the same orderNumber or created as a new order. The message is kept so it can be parsed again later.

How parsing works

  1. Normalize. 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 the forwarder's. A Discord payload is flattened into text, and a map of its embed fields is built.

  2. Check the sender (email only). If the email source has a non-empty allowedSenders list, the normalized From must match it, whichever path the email came in by: IMAP, Gmail, Microsoft, forwarding, or manual import. Emails that fail are refused before parsing and logged to the intake log with outcome rejected. A Gmail forwarding-confirmation email sent to a forwarding source is not parsed either: its code and link are stored on the source (see Email sources).

  3. Select a shape. A shape recognizes one kind of message and says which fields to extract from it. Candidates are tried in this order, and the first one whose match rules all pass is used:

    1. For Discord intake, the template pinned on the bot (templateId). It is applied without checking its rules.

    2. Your custom templates for that channel, newest first (the order GET /api/templates/custom returns them in).

    3. Built-in shapes: retailer emails and known monitor platforms.

    An email source with a non-empty templateIds list considers only those shapes, in that order. A custom template with no match rules never matches by itself. It only runs when a bot pins it.

  4. Extract. The matched shape's mappings run first. Generic patterns then fill whatever is still missing: order number, item, total, quantity, tracking number, carrier, and store.

  5. Classify. Store, event type, and status are derived. See Orders → Status.

  6. Ignore non-order email. An email that matched no shape is ignored if any of these is true: it isn't from a known retailer domain, it has no order number, its event type is unknown, or its subject is known to be unrelated to purchases. Discord intake is never ignored, because a bot URL receives only bot traffic.

  7. Ingest. If the order number matches an existing order, the message is merged into it. Otherwise a new order is created. Then the matching webhook events fire.

Every email that creates or merges an order, or is refused (sender not allowed, plan limit), is written to the intake log (GET /api/webhook-logs, channel email) whatever path it came by. This is what the dashboard's Inbox page shows. Ignored non-order mail and duplicates are not logged.

Note: Every JSON endpoint, including the intake endpoints on this page, refuses request bodies over 2 MiB with 413.

To make parsing work for a sender Shippified doesn't recognize, write a custom template. See the custom templates guide for the concepts and the Templates API for the endpoints.

Discord-format webhook intake

Each intake bot has its own URL. The URL accepts the same JSON body a Discord "Execute Webhook" call does. Point a checkout monitor's webhook setting at this URL 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, from GET /api/auth/me → user.webhookHandle. Your user ID also works here, for URLs created before handles existed.

botSlug

The slug of a bot you created. See Managing bots.

No Authorization header is used. The handle and slug together act as the credential, so keep the full URL private.

Note: Query strings such as Discord's ?wait=true are accepted and ignored. Only JSON bodies are supported. Multipart uploads with payload_json and file attachments are not parsed.

Payload

The body uses Discord's webhook message shape. These fields 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" },
      "image": { "url": "https://cdn.example.com/p/12345-large.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, case-insensitively. For each name, the first embed that has it wins.

  • Discord formatting in values, such as ||spoiler||, **bold**, __underline__, and backticks, is removed.

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

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

Example

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": { … }, "forward": { … }, "merged": false }

Free-plan monthly limit reached

202

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

Unknown handle

401

{ "error": "Unknown webhook user" }

Payload didn't produce an order

202

{ "ignored": true }

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. Includes Retry-After: 60.

On success, order is the full order object after the merge or create. forward reports what happened with the bot's optional outputWebhook:

{ "forwarded": true, "status": 204, "attempts": 1 }

forward field

Meaning

forwarded

true if the parsed-order summary was posted to the bot's output webhook, or, if the bot has none, to the account-wide fallback (outputWebhook in settings). false if neither is set or the post failed.

status

HTTP status from the last attempt.

error

Error from the last attempt, if it failed.

attempts

Number of attempts made (0 to 3). 0 with an error means the URL was refused before sending, for example because it resolves to a private address.

Note: A plan-limit rejection returns 202, not an error. This stops Discord and monitors from retrying. Check for rejected: true in the body.

Note: The response waits for the output-webhook forward to finish: up to 3 attempts, with an 8-second timeout each and backoff of 1.5 seconds and then 4 seconds. A slow or failing output webhook can therefore delay the response by several seconds. 429 and 5xx from the output webhook are retried. Other 4xx responses are not.

Every post that reaches a bot is recorded in your intake log, with outcome set to created, merged, review, or rejected:

  • review when the order has status issue, or when forwarding failed, to the bot's outputWebhook or to the account-wide fallback. The log's error names which one (output webhook forward failed after … or fallback webhook forward failed after …).

  • rejected for plan-limit rejections (plan_limit_reached (used/cap)) and for posts that didn't produce an order (not ingested: …).

Read one bot's history with GET /api/bots/:id/logs (newest first, ?limit= default 200, max 500), or the whole workspace's with GET /api/webhook-logs.

Rate limits

Scope

Limit

Per client IP, across all bots

60 requests per minute

Per bot URL

60 requests per minute

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

Deduplication

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

Managing bots

Method

Path

Purpose

GET

/api/bots

List bots. Paginated.

GET

/api/bots/:id

Get one bot.

POST

/api/bots

Create a 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 in place.

GET

/api/bots/:id/logs

This bot's intake log, newest first. ?limit= from 1 to 500, default 200. Returns { "logs": [ … ] }.

POST /api/bots body fields, all optional:

Field

Notes

name

Defaults to "New Bot".

slug

Lowercased. Characters other than a-z, 0-9, and - become -. Defaults to a value derived from name. If the slug is already used on your account, a suffix is added (-2, -3, …).

templateId

ID of a built-in webhook shape or one of your custom templates. It is applied to every post, without checking match rules. Omit it (or PATCH it to "") to use automatic selection, which the dashboard labels Auto-detect (recommended).

outputWebhook

A Discord webhook URL that receives a short summary of each parsed order. Must be a public http(s) URL: one that resolves to a private, loopback, or link-local address is refused with 400.

avatar, templateHint, samplePayload

Display and reference fields. They don't affect parsing.

type

monitor (default), dropship, or generic. A label for your own reference; the dashboard also uses it to pick the shape of its test payload. It never affects 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"}'

Returns 201 with the bot. Its slug is the last segment of the intake URL. For the dashboard walkthrough, see the Discord monitor bots guide.

Manual email import

POST /api/email/import

Parses one raw email and ingests it through an email source you choose. This creates a real order. Because you picked this message on purpose, it is never ignored as non-order mail. If no shape matches, it becomes an order anyway, with status issue if nothing useful could be extracted. To see how a message would parse without creating anything, use POST /api/templates/custom/detect (see the Templates API).

Body field

Type

Required

Notes

emailSourceId

string

Yes

One of your email sources. The source's template list and sender allowlist apply.

raw

string

Yes

The email. Full RFC 822 source (headers and MIME) gives the best results. Pasted HTML or plain text is also accepted.

from

string

No

Fallback sender address, used only when raw has no From: header. Name <[email protected]> is accepted.

curl -s -X POST https://shippified.net/api/email/import \
  -H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- <<EOF
{
  "emailSourceId": "email_m1x2y3z5",
  "raw": $(jq -Rs . < order-confirmation.eml)
}
EOF

Responses

Situation

Status

Body

Order created or merged

202

Workspace snapshot fields (as in GET /api/state) plus order and merged

Message already ingested (same Message-ID)

200

Workspace snapshot plus order, merged: false, duplicate: true

emailSourceId missing or unknown

404

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

Sender not in the source's allowedSenders

403

{"error": "Sender … is not in this source's allowed senders.", "blockedSender": "…"}

Source has allowedSenders and no sender could be determined

403

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

raw empty

400

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

Free-plan monthly limit reached

429

{"error": "…", "reason": "plan_limit_reached", "cap": …, "used": …}

Note: A successful import response includes the whole workspace snapshot (user, bots, orders, …), so it grows with the workspace. Read order and merged and ignore the rest.

Sender allowlist entries can be an exact address ([email protected]), a domain wildcard (*@example.com), or a bare domain (example.com). A domain entry also covers its subdomains (example.com matches [email protected]). All comparisons are case-insensitive. An empty list allows any sender. The check is made against the email's own sender after a manual forward is unwrapped, and it applies to every path, not only this endpoint. Imports, including refused ones, are recorded in the intake log with channel email.

Email sources

An email source is a mailbox Shippified reads, or a forwarding address it receives mail at. The endpoints below manage sources. For connecting a provider step by step, including the Gmail and Microsoft OAuth flows, 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. Other fields are ignored.

DELETE

/api/email-sources/:id

Delete a source.

POST

/api/email-sources/:id/poll

Poll the mailbox now.

POST

/api/email-sources/:id/rebuild

Read the mailbox again from a past date and rebuild orders.

Create a source

Body field

Notes

provider

imap, google, microsoft, or forwarding. Any other value 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 priority order. Empty means all shapes.

allowedSenders

Sender allowlist, in the formats described under Manual email import.

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 with status needs_auth and become connected after the OAuth flow finishes in the dashboard.

  • forwarding sources get a generated address, <slug>@shippified.net, in forwardingConfig.inboundAddress (also copied to address). Mail forwarded to that address 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": ["*@example.com"]}'

Update a source

PATCH /api/email-sources/:id accepts only the user-editable fields listed above. status, provider tokens, the forwarding slug and sync cursors are owned by the server and can't be set by a client.

For an imap source, imapConfig takes the same shape as on create. Omit password (or send an empty string) to keep the stored one. Saving imapConfig on a source whose status is auth_failed sets it back to connected and clears lastError, so the next poll tries the new credentials.

Sync state on a source

Field

Meaning

status

connected, needs_auth, or auth_failed. Pollers set auth_failed when the provider rejects the sign-in, and stop polling that source until it's reconnected (OAuth) or its credentials are edited (IMAP).

lastPolledAt

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

lastError

The last poll's error, if it failed. Cleared by the next successful poll.

rebuilding, lastRebuild

See Rebuild from mailbox.

forwardingConfig.pendingVerification

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

Poll now

POST /api/email-sources/:id/poll

Shippified already polls connected mailboxes about once a minute. This endpoint runs a poll right away and waits for it to finish.

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

ingested counts messages that created or merged an order. duplicates counts messages already seen. ignored counts non-order mail. Messages refused by allowedSenders aren't counted in any of these; they appear in the intake log. error is set when the poll failed, for example because of rejected credentials. A source in auth_failed isn't polled and returns error: "Source paused (auth_failed)". For forwarding sources, the result is zeros plus an error explaining that there is nothing to poll. This is the dashboard's Sync now button.

Rebuild from mailbox

POST /api/email-sources/:id/rebuild

Body field

Notes

sinceDays

Integer from 1 to 365. Default 30.

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}'

Returns 202 {"started": true, "since": "<ISO timestamp>"} and runs in the background:

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

  2. Deletes orders from this source received on or after since that came only from email and have no hand-entered data (no salePriceCents, no userEdited entries).

  3. Reads every message in the mailbox since that date and runs it through the current parser. At most 5,000 messages are read; when there are more, the most recent are kept.

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

Error

Status

forwarding source (no mailbox to read)

400

sinceDays out of range

400

A rebuild is already running for this source

409

See the rebuild guide for when to use this instead of re-parse. Re-parse only processes messages that are already stored, and it doesn't read the mailbox.

Inbound mail worker (operator only)

POST /api/inbound/mail is how mail sent to forwarding addresses reaches Shippified. A Cloudflare Email Worker (the cloudflare-email-worker package) receives the mail and posts it here. It is authenticated with a deployment-wide shared key, so individual users cannot call it. It is documented here for operators running their own deployment, and for anyone building a replacement worker.

POST /api/inbound/mail
x-shippified-inbound-key: <deployment inbound key>
Content-Type: application/json

The key can be sent in the x-shippified-inbound-key header (preferred, because it stays out of access logs) or as a ?key= query parameter, which is what the reference worker uses. The endpoint fails closed: if the deployment has no inbound key configured, every request gets 503.

{
  "rawRfc822": "From: Example Store <[email protected]>\r\nTo: [email protected]\r\n…",
  "recipient": "[email protected]",
  "sender": "[email protected]"
}

Field

Required

Notes

rawRfc822

Yes

The complete raw message.

recipient

Yes

The forwarding address. Its local part (before @) selects the source.

sender

No

Envelope sender. Accepted but not used: allowedSenders is checked against the message's own From (after unwrapping a manual forward), not the envelope sender, which is usually the forwarding mailbox.

Result

Status

Body

No inbound key configured on the server

503

{"error": "Inbound mail is disabled: …"}

Wrong or missing key

401

{"error": "Invalid inbound key."}

Body over 2 MiB

413

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

Missing rawRfc822 or recipient

400

{"error": "Missing rawRfc822 or recipient."}

Empty local part

400

{"error": "Invalid recipient address."}

No source for that address

404

{"error": "No forwarding source for slug …"}

Plan limit reached

402

{"error": "plan_limit_reached", "cap": …, "used": …}

Ingested

202

{"orderId": "…", "merged": false}

Duplicate

202

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

Sender not in allowedSenders

202

{"blocked": true} (logged to the intake log as rejected)

Not order mail, or a Gmail forwarding confirmation

202

{"ignored": true}

Non-order mail, refused senders and duplicates return 202 so the worker doesn't retry or bounce them. The reference worker rejects messages over 5 MiB itself. It bounces the message back to the sender on a 4xx response (so unknown addresses, the plan limit, and bodies over the API's 2 MiB limit bounce) and logs 5xx responses without bouncing.

Was this page helpful?
Getting orders in