📐REST API conventions
Base URL, request and response rules, errors, pagination, rate limits, IDs and dates that apply to every endpoint.
- Written for
- Developers calling the Shippified API
- Applies to
- All plans
- Deprecated
- + Deprecated
These rules apply to every endpoint under https://shippified.net/api. Read this page once; the resource pages assume it.
Base URL
https://shippified.net/apiPaths in these docs include the /api prefix: GET /api/orders means https://shippified.net/api/orders. Every request except the public endpoints needs an Authorization: Bearer header; see Authentication.
Requests
Methods.
GET,POST,PATCHandDELETE. There is noPUT.JSON bodies. Send
Content-Type: application/json. The server parses the body as JSON either way.Invalid JSON isn't rejected up front. A body that doesn't parse is kept as a raw string, so the endpoint sees its fields as missing and usually answers
400with a field-specific message such asitemSummary is required.2 MiB body limit. A larger body gets
413 {"error": "Request body too large (limit 2 MiB)."}. For moderately oversized uploads the server reads and discards the rest first, so your client sees the413instead of a broken connection. Very large uploads get the413and a closed connection.Unknown fields are ignored. Each endpoint reads only the fields it documents. Keys named
__proto__,prototypeorconstructorare stripped at any depth.Filters and paging go in the query string:
?status=shipped&limit=100.
Responses
Every JSON response has Content-Type: application/json; charset=utf-8. Most endpoints return the resource itself; a few wrap it, such as POST /api/orders → { "order": …, "merged": … }. Each resource page shows the exact shape.
Fields without a value are left out, not sent as null. An order with no tracking number simply has no trackingNumber key.
Errors
Every error has the same shape:
{ "error": "Human-readable message" }Some add machine-readable fields next to error:
Extra fields | Returned by | Meaning |
|---|---|---|
|
| The free plan's monthly order limit is used up. |
|
| The email's real sender isn't in the source's allowed senders. Absent when the email has no sender at all. |
|
| Verify the account email before upgrading. |
|
| A short code for the sign-in failure. |
Branch on the status code (and reason where present), not on the wording of error. Messages are written for people and can change.
Status codes
Code | Used for |
|---|---|
| Reads, updates, deletes ( |
| A resource was created: orders, API keys, webhook subscriptions, bots, email sources, custom templates, recurring costs, shares. |
| Intake that processes a message (Discord intake, |
| CORS preflight ( |
| Missing or invalid input: |
| Missing, invalid, revoked or expired credentials, or an API key on an endpoint that needs a signed-in session. |
| Only for two rules: an email sender blocked by the allow-list, and upgrading before the email is verified. Resources from other accounts return |
| The resource doesn't exist or belongs to another account. Unknown |
| Username taken, email already registered, or a mailbox rebuild already running. |
| Body over 2 MiB. |
|
|
| A rate limit, a resource cap (25 API keys, 25 webhook subscriptions), or the monthly plan limit on a create. |
| Unhandled error. The body is still |
| An upstream service (the sign-in service, Discord) couldn't be reached. |
| A feature isn't configured on this deployment (for example Google sign-in, billing, or webhook-secret encryption), or |
Pagination
Collections that can grow large use offset pagination and one envelope.
Query parameter | Default | Bounds | Notes |
|---|---|---|---|
|
| 1 to 200 | Above 200 is capped at 200. |
|
| 0 or more | Items to skip. Non-numeric values become 0. |
{
"items": [ … ],
"total": 342,
"limit": 50,
"offset": 100,
"hasMore": true
}Field | Meaning |
|---|---|
| This page. |
| All matches after filters, before paging. |
| The values the server actually used, after clamping. |
|
|
Endpoints that use this envelope:
Endpoint | Order |
|---|---|
|
|
|
|
| Not guaranteed |
| Not guaranteed |
| Not guaranteed |
Walk every page
- curl + jq
- TypeScript SDK
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// Pages with limit=200 until hasMore is false.
const all = await shippified.listOrders();
// Or page yourself:
let offset = 0;
for (;;) {
const page = await shippified.orders.list({ limit: 200, offset });
for (const order of page.items) handle(order);
if (!page.hasMore) break;
offset += page.limit;
}Pages are cut from a list sorted newest first. If orders arrive while you page, items shift and you can see some twice. Deduplicate by id.
A few small lists aren't paginated and use their own wrapper:
Endpoint | Shape | Limit |
|---|---|---|
|
| At most 25 keys exist. |
|
| None. Newest first, which is also the order templates are tried in. |
|
| None. |
|
|
|
|
|
|
|
|
|
Rate limits and caps
Only the endpoints below are rate-limited. Each limit is a sliding 60-second window counted in the server's memory.
Scope | Limit | Over the limit |
|---|---|---|
Sign-in endpoints: | 10 a minute per client IP, shared |
|
Discord intake, per client IP across all bots | 60 a minute |
|
Discord intake, per bot URL | 60 a minute |
|
Embed copier submission URL, per workspace | 30 a minute |
|
The client IP is the first address in X-Forwarded-For, or the connection's address when that header is absent.
Resource caps:
Resource | Cap | When exceeded |
|---|---|---|
API keys per account | 25 |
|
Webhook subscriptions per account | 25 |
|
New orders a month on the free plan | 100, counted from the 1st of the month in your account's timezone. Applies only when billing is switched on for the deployment. |
|
The plan limit counts only new orders. Messages that merge into an existing order (shipping and delivery emails, for example) are never blocked. GET /api/billing/usage returns plan, used, cap (null means unlimited), resetsAt and billingEnabled.
Duplicates and retries
There's no Idempotency-Key header. Duplicates are prevented by the data instead:
Path | Rule |
|---|---|
| An |
Email (import, polling, forwarding) | Deduplicated by |
Discord intake | Not deduplicated. Discord payloads carry no message ID, and two identical checkouts can be two real orders. Posts with the same order number still merge. |
Before retrying a failed POST /api/orders, make sure the body has an orderNumber, so a retry merges instead of duplicating. Or search first with GET /api/orders?q=<order number>.
IDs
IDs are opaque strings with a type prefix. Don't parse them or rely on their length.
Resource | Prefix | Example |
|---|---|---|
Order |
|
|
User |
|
|
API key |
|
|
Bot |
|
|
Email source |
|
|
Custom template |
|
|
Recurring cost |
|
|
Webhook subscription |
|
|
Webhook event |
|
|
Intake log entry |
|
|
Secrets look similar but aren't IDs: API keys start with sk_ and webhook signing secrets with whsec_.
Dates and money
Kind | Format | Example | Fields |
|---|---|---|---|
Timestamp | ISO 8601, UTC, milliseconds |
|
|
Calendar date |
|
|
|
Money | Integer cents |
|
|
Display money | Text as it appeared in the message |
| Order |
total and price are strings copied from the source message. Do arithmetic only on the …Cents fields.
CORS
JSON responses carry:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: authorization,content-typeOPTIONS to any path gets 204 with no body. Authentication uses the Authorization header, not cookies.
CORS lets a browser call the API, but never put an API key in a web page: it grants full access to the workspace. Call Shippified from your own backend.
Public endpoints
These need no token:
Endpoint | Returns |
|---|---|
|
|
| The OpenAPI 3.1 spec. |
| An interactive API reference page (HTML). |
|
|
| A public profile. |
| Which sign-in methods the deployment has switched on. |
| Discord intake. The URL is the credential. See Getting orders in. |
Endpoint map
Everything else needs a token. The resource pages cover the first group in depth; the rest are summarised here and fully specified in the API reference.
Area | Endpoints | Documented in |
|---|---|---|
Orders and tracking |
| |
Bots, email sources, import, logs, inbox |
| |
Templates |
| |
Outbound webhooks |
| |
API keys, export, deletion |
| |
Workspace snapshot |
| |
Recurring costs |
| |
Insights |
| |
Share cards |
| |
Workspace settings |
| |
Account profile |
| |
Billing |
| |
Embed copier |
| |
Discord bot |
|
Workspace snapshot
GET /api/state returns the whole workspace in one response: user, settings, bots (each with stats and recentOrders), emailSources, every order, analytics, templates (built-in and custom) and subscriptions. The dashboard uses it. Integrations should prefer the paginated endpoints: this response grows with the workspace.
Troubleshooting
Every request returns 401
The header must be exactly Authorization: Bearer <token>, with the full key including sk_. Check the key is still listed under Settings → Developer. See Authentication.
My POST fields are ignored or I get "…is required"
Check the body is valid JSON and sent with Content-Type: application/json. Invalid JSON arrives as a plain string, so every field looks missing. Field names are case-sensitive (itemSummary, not item_summary).
A status filter returns everything
GET /api/orders ignores a status value it doesn't recognise instead of failing. Use one of ordered, shipped, delivered, canceled, issue.
I get 404 for an ID I can see in the dashboard
The token belongs to a different account than the one you're signed in to. Every read is scoped to the token's account, and other accounts' resources return 404.