Getting orders in
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Getting orders in
Shippified creates orders from messages. There are four ways to send messages in programmatically:
Path | Best for | Auth |
|---|---|---|
Checkout monitors, bots, and any script that can post a Discord webhook payload | The URL is the credential | |
Pushing individual raw emails from your own mail tooling | API key | |
Letting Shippified poll a mailbox (IMAP, Gmail, Microsoft) or receive forwarded mail | API key to manage | |
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
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
FromandSubjectare the retailer's, not the forwarder's. A Discord payload is flattened into text, and a map of its embed fields is built.Check the sender (email only). If the email source has a non-empty
allowedSenderslist, the normalizedFrommust 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 outcomerejected. 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).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:
For Discord intake, the template pinned on the bot (
templateId). It is applied without checking its rules.Your custom templates for that channel, newest first (the order
GET /api/templates/customreturns them in).Built-in shapes: retailer emails and known monitor platforms.
An email source with a non-empty
templateIdslist 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.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.
Classify. Store, event type, and status are derived. See Orders → Status.
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.
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 |
|---|---|
| Your account's unguessable handle, from |
| The |
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=trueare accepted and ignored. Only JSON bodies are supported. Multipart uploads withpayload_jsonand 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
fieldsare 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
httpsthumbnail.urlorimage.urlbecomes the order'simageUrl.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 |
|
|
Free-plan monthly limit reached |
|
|
Unknown handle |
|
|
Payload didn't produce an order |
|
|
Unknown bot slug |
|
|
Body over 2 MiB |
|
|
Rate limit |
| See Rate limits. Includes |
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 }
| Meaning |
|---|---|
|
|
| HTTP status from the last attempt. |
| Error from the last attempt, if it failed. |
| Number of attempts made (0 to 3). |
Note: A plan-limit rejection returns
202, not an error. This stops Discord and monitors from retrying. Check forrejected: truein 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.
429and5xxfrom the output webhook are retried. Other4xxresponses are not.
Every post that reaches a bot is recorded in your intake log, with outcome set to created, merged, review, or rejected:
reviewwhen the order has statusissue, or when forwarding failed, to the bot'soutputWebhookor to the account-wide fallback. The log'serrornames which one (output webhook forward failed after …orfallback webhook forward failed after …).rejectedfor 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 |
|---|---|---|
|
| List bots. Paginated. |
|
| Get one bot. |
|
| Create a bot. |
|
| Update |
|
| Delete a bot. Orders it created stay in place. |
|
| This bot's intake log, newest first. |
POST /api/bots body fields, all optional:
Field | Notes |
|---|---|
| Defaults to |
| Lowercased. Characters other than |
| 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 |
| A Discord webhook URL that receives a short summary of each parsed order. Must be a public |
| 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"}'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 |
|---|---|---|---|
| string | Yes | One of your email sources. The source's template list and sender allowlist apply. |
| string | Yes | The email. Full RFC 822 source (headers and MIME) gives the best results. Pasted HTML or plain text is also accepted. |
| string | No | Fallback sender address, used only when |
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)
}
EOFResponses
Situation | Status | Body |
|---|---|---|
Order created or merged |
| Workspace snapshot fields (as in |
Message already ingested (same Message-ID) |
| Workspace snapshot plus |
|
|
|
Sender not in the source's |
|
|
Source has |
|
|
|
|
|
Free-plan monthly limit reached |
|
|
Note: A successful import response includes the whole workspace snapshot (
user,bots,orders, …), so it grows with the workspace. Readorderandmergedand 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 |
|---|---|---|
|
| List sources. Paginated. Credentials are never returned. |
|
| Get one source. |
|
| Create a source. |
|
| Update |
|
| Delete a source. |
|
| Poll the mailbox now. |
|
| Read the mailbox again from a past date and rebuild orders. |
Create a source
Body field | Notes |
|---|---|
|
|
| Defaults to |
| Display address. For |
| Shape or template IDs to try, in priority order. Empty means all shapes. |
| Sender allowlist, in the formats described under Manual email import. |
| Required for |
googleandmicrosoftsources start with statusneeds_authand becomeconnectedafter the OAuth flow finishes in the dashboard.forwardingsources get a generated address,<slug>@shippified.net, inforwardingConfig.inboundAddress(also copied toaddress). 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 |
|---|---|
|
|
| When the last poll (or, for |
| The last poll's error, if it failed. Cleared by the next successful poll. |
| See Rebuild from mailbox. |
|
|
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 |
|---|---|
| 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:
Signs in and checks that the mailbox can be read. If it can't, nothing is deleted, and
lastRebuild.errorstarts withCouldn't read the mailbox, so nothing was changed:.Deletes orders from this source received on or after
sincethat came only from email and have no hand-entered data (nosalePriceCents, nouserEditedentries).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 |
|---|---|
|
|
|
|
A rebuild is already running for this source |
|
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/jsonThe 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 |
|---|---|---|
| Yes | The complete raw message. |
| Yes | The forwarding address. Its local part (before |
| No | Envelope sender. Accepted but not used: |
Result | Status | Body |
|---|---|---|
No inbound key configured on the server |
|
|
Wrong or missing key |
|
|
Body over 2 MiB |
|
|
Missing |
|
|
Empty local part |
|
|
No source for that address |
|
|
Plan limit reached |
|
|
Ingested |
|
|
Duplicate |
|
|
Sender not in |
|
|
Not order mail, or a Gmail forwarding confirmation |
|
|
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.