REST API conventions

AdminUpdated Sep 24, 2026

REST API conventions

These conventions apply to every endpoint under https://shippified.net/api.

Base URL

https://shippified.net/api

All paths in these docs include the /api prefix, as in GET /api/orders → https://shippified.net/api/orders. Only HTTPS is supported.

Requests

  • Methods. The API uses GET, POST, PATCH, and DELETE. There is no PUT.

  • Bodies are JSON. Send Content-Type: application/json. The server parses the body as JSON whether or not the header is set.

  • Invalid JSON is not rejected up front. A body that is not valid JSON is kept as a raw string. The endpoint then sees missing fields and usually returns 400 with a field-specific message.

  • Body size limit: 2 MiB. A larger body gets 413 with {"error": "Request body too large (limit 2 MiB)."}. The server reads and discards the rest of a moderately oversized upload so your client receives the 413 instead of a broken connection. For very large uploads it answers and closes the connection.

  • Prototype keys are stripped. Keys named __proto__, prototype, or constructor are dropped at any depth.

  • Unknown fields are ignored. Each endpoint reads only the fields it documents.

  • Query parameters are used for filters and pagination on GET requests, as in ?status=shipped&limit=100.

Responses

Every response from the JSON API has Content-Type: application/json; charset=utf-8. Most endpoints return the resource itself. A few return a wrapper, such as { "order": …, "merged": … } from POST /api/orders. Each endpoint page shows its exact shape.

Fields with no value are left out of the response. They are not sent as null. For example, an order without tracking has no trackingNumber key at all.

Errors

Errors use one shape:

{ "error": "Human-readable message" }

Some errors add machine-readable fields next to error:

Extra field(s)

Returned by

Meaning

reason, cap, used

POST /api/orders, POST /api/email/import (429), Discord intake (202)

Monthly plan limit reached. reason is "plan_limit_reached".

cap, used

Inbound mail (402)

Monthly plan limit reached. Here error itself is "plan_limit_reached".

blockedSender

POST /api/email/import (403)

The real sender (after unwrapping a manually forwarded email) is not in the source's allowed senders. Missing when the email has no sender at all.

Use the status code to drive program logic. The wording of error messages can change.

Status codes

Code

Used for

200 OK

Successful reads, updates, deletes ({"ok": true}), and actions.

201 Created

A resource was created: orders, API keys, webhook subscriptions, bots, email sources, custom templates, shares.

202 Accepted

Intake endpoints that process a message: Discord intake, POST /api/email/import, inbound mail, and the background POST /api/email-sources/:id/rebuild.

204 No Content

CORS preflight (OPTIONS) responses. These have no body.

400 Bad Request

Missing or invalid input, for example itemSummary is required, an invalid receivedAt or eventType on POST /api/orders, an invalid regex in a template, a webhook URL that resolves to a private address, or sinceDays must be 1–365..

401 Unauthorized

Missing, invalid, revoked, or expired credentials. Also returned for an unknown handle on Discord intake, and when an API key is used on an endpoint that needs a signed-in session (account export and deletion).

402 Payment Required

Inbound mail only: plan limit reached.

403 Forbidden

POST /api/email/import: the email's sender is not in the source's allowed senders.

404 Not Found

The resource doesn't exist or belongs to another account. Unknown /api routes also return {"error": "Not found"}.

409 Conflict

Username taken, email already registered, or a mailbox rebuild already running.

413 Content Too Large

Request body over 2 MiB.

422 Unprocessable Content

POST /api/email/import: the email couldn't be read as an order.

429 Too Many Requests

Rate limit hit, resource cap reached (25 API keys, 25 webhook subscriptions), or monthly plan limit reached on a create.

500 Internal Server Error

Unhandled error. The body is {"error": "<message>"}.

503 Service Unavailable

A dependency is not configured on this deployment (for example Google sign-in, webhook secret encryption, or the inbound mail key), or /api/healthz found persistence unhealthy.

Note: Resources owned by another account return 404, never 403. Every read and write is scoped to the authenticated account.

Pagination

Collection endpoints that can grow large use offset pagination and a shared envelope.

Query parameter

Default

Bounds

Notes

limit

50

1 to 200

Values above 200 are capped at 200. Non-numeric values or 0 fall back to 50.

offset

0

≥ 0

Number of items to skip.

Response envelope:

{
  "items": [],
  "total": 342,
  "limit": 50,
  "offset": 100,
  "hasMore": true
}

Field

Meaning

items

The page of results.

total

Total matches after filters, before pagination.

limit / offset

The values the server applied, after clamping.

hasMore

true when offset + items.length < total.

Endpoints that use this envelope:

Endpoint

Default sort

GET /api/orders

receivedAt, newest first

GET /api/bots

Not guaranteed (typically newest first)

GET /api/email-sources

Not guaranteed (typically newest first)

GET /api/subscriptions

Not guaranteed (typically newest first)

GET /api/webhook-subscriptions

createdAt, newest first

Walking every page:

offset=0
while :; do
  page=$(curl -s "https://shippified.net/api/orders?limit=200&offset=$offset" \
    -H "Authorization: Bearer $SHIPPIFIED_API_KEY")
  echo "$page" | jq -c '.items[]'
  [ "$(echo "$page" | jq -r .hasMore)" = "true" ] || break
  offset=$((offset + 200))
done

Note: Pagination is offset-based over a list sorted newest first. If orders arrive while you are paging, items can shift between pages, so you may see some twice. Deduplicate by id when walking a live list.

A few smaller lists are not paginated and use their own wrapper:

Endpoint

Shape

Limit

GET /api/account/api-keys

{ items }

At most 25 keys exist.

GET /api/shares

{ shares }

None.

GET /api/webhook-logs

{ logs }

?limit=, default 100, max 500. Newest first.

GET /api/inbox

{ items, total }

?limit=, default 200, max 500. Optional ?sourceId=.

GET /api/bots/:id/logs

{ logs }

?limit=, default 200, max 500. Newest first. One bot's intake log, filtered on the server.

GET /api/templates/custom

{ items }

None. Newest first, which is also the order custom templates are tried in.

Rate limits and caps

Only the endpoints below are rate-limited. Limits use a sliding 60-second window and are tracked in memory per server process.

Scope

Limit

Over-limit response

Auth: POST /api/auth/register, /login, /forgot-password, /reset-password, /discord/widget-verify

10 requests per minute per client IP

429, Retry-After: 60

Discord intake, per client IP (all bots combined)

60 requests per minute

429, Retry-After: 60, {"error": "Webhook ingest rate exceeded (60/min per IP)"}

Discord intake, per bot URL

60 requests per minute

429, Retry-After: 60, {"error": "Webhook ingest rate exceeded for this bot (60/min)"}

The client IP is the leftmost X-Forwarded-For entry, or the socket address when that header is absent.

Resource caps:

Resource

Cap

Response when exceeded

API keys per account

25

429

Webhook subscriptions per account

25

429

New orders per month on the free plan

100 (only when billing is enabled on the deployment)

429 on POST /api/orders and POST /api/email/import. 202 with rejected: true on Discord intake. 402 on inbound mail.

The plan limit applies only to creating new orders. Merges into existing orders, such as status-update emails, are never blocked. GET /api/billing/usage returns the current plan, used, cap, and resetsAt.

Idempotency

The API does not support an Idempotency-Key header. Duplicate protection comes from the data model instead:

Path

Dedup rule

POST /api/orders

If orderNumber matches an existing order, the request merges into that order (merged: true) instead of creating a new one. Without an orderNumber, every call creates a new order.

Email intake (import, polling, forwarding)

Deduplicated by Message-ID, or by a SHA-256 of the raw message when there is no Message-ID. A repeated message is reported as a duplicate and does not change anything. POST /api/email/import returns 200 with duplicate: true for a repeat.

Discord intake

Not deduplicated. Discord payloads have no message ID, and two identical checkouts can be two real orders. Posts that share an order number still merge.

Important: Before retrying a failed POST /api/orders, include an orderNumber so a retry merges rather than duplicates. Or GET /api/orders?q= first to check whether the order already exists.

IDs

IDs are opaque strings with a type prefix. Don't parse them or rely on how long they are.

Resource

Prefix

Example

Order

ord_

ord_mpvu9uim_aeh5t

User

usr_

usr_4f1c2a9b7e0d3c11

API key

key_

key_m1x2y3z4_a1b2c3

Webhook subscription

whsub_

whsub_m1x2y3z4_k9d2q

Webhook event (delivery envelope)

evt_

evt_m1x2y3z4_0a1b2c3d

Bot

bot_

bot_m1x2y3z4

Email source

email_

email_m1x2y3z4

Custom template

tmpl_

tmpl_m1x2y3z4

Recurring cost

sub_

sub_m1x2y3z4

Intake log entry

log_

log_m1x2y3z4_x7k2p

Some values are secrets rather than IDs. API keys start with sk_ and webhook signing secrets start with whsec_.

Timestamps, dates, and money

Kind

Format

Example

Fields

Timestamp

ISO 8601 UTC with milliseconds

2026-09-24T15:02:11.482Z

receivedAt, updatedAt, createdAt, lastUsedAt, lastFiredAt

Calendar date

YYYY-MM-DD

2026-09-27

estimatedDelivery, actualDelivery, the from/to order filters

Money

Integer cents

49999 = $499.99

costCents, salePriceCents, subscription costCents

Display money

Free-text string as parsed

"$499.99"

Order total, price

Note: total and price are strings copied from the source message. Do arithmetic on costCents and salePriceCents only.

CORS

Every JSON response includes:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: authorization,content-type

OPTIONS requests to any path get 204 with no body. By default the allowed origin is *. A deployment can restrict it to a list of origins through server configuration. Authentication uses the Authorization header, not cookies, so credentialed-CORS rules do not apply.

Important: CORS lets a browser call the API, but you should not put an API key in browser code. A key grants full access to the workspace. Proxy calls through your own backend.

Public endpoints

These endpoints require no token:

Endpoint

Purpose

GET /api/healthz

Health check with uptime, version, persistence status, and counts. Returns 503 when persistence is unhealthy.

GET /api/openapi.json

The OpenAPI 3.1 spec.

GET /api

Interactive API reference (HTML).

GET /api/landing-stats

Aggregate platform stats.

GET /api/profile/:userIdOrUsername

A public profile, when the owner has made it public.

POST /api/inbound/mail

Operator-only drop-off for the email worker. It is not open: it needs the deployment's inbound key and returns 503 when none is configured. See Getting orders in.

POST /api/webhooks/discord/:handle/:slug

Discord intake. The URL is the credential. See Getting orders in.

Workspace snapshot

GET /api/state returns the whole workspace in one response: user, settings, bots (each with stats and recentOrders), emailSources, orders (all of them), analytics, templates (built-in and custom), and subscriptions. The dashboard uses it. Integrations should prefer the paginated resource endpoints, because this response grows with the size of the workspace.

Was this page helpful?
REST API conventions