Make the account's hosted test inbox: a test-only subscription to every event this API offers, whose deliveries are kept for you to read with GET /v1/webhooks/inbox instead of being POSTed anywhere — a test order's events, signed and in full, with no endpoint, tunnel or install of your own. Takes no body. One inbox per account: a later call answers the same inbox with created: false and secret: null (rotate its secret with POST /v1/webhooks/{id}/rotate-secret if you lost it). Its url is the dashboard page that shows it and is never dialled; its url and mode cannot be changed. A test key only: a live key is 400 test_mode_only, and a body field (a url, a mode) is 400 validation_error — to receive events at a URL of your own, use POST /v1/webhooks.
- Credential
- An API key in
x-api-key - Scopes
webhooks:manage
Parameters
Idempotency-Keyheader · string (uuid)Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422
idempotency_key_reuserather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503idempotency_unavailablewithRetry-After, and nothing runs or is charged: retry with the same key.
Request body application/json
Request
POST /api/v1/webhooks/inbox
curl -X POST "https://portal.printedsimply.com/api/v1/webhooks/inbox" \
-H "x-api-key: ps_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-webhook-inbox-$(date +%s)" \
-d '{}'Response 200
The inbox, and on the call that made it the signing secret in the clear — the one time it is returned, other than a rotation.
data
idstringrequiredurlstringrequiredeventsstring[]requiredactivebooleanrequiredmodestringrequiredWhich order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (
all) rather than as the stored string.One of
alltestlivecreated_atstring (date-time)requiredsecret_rotated_atstring (date-time) | nullprevious_secret_expires_atstring (date-time) | nullDuring a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.
rotation_pendingbooleanTrue while the previous secret is still inside its window.
inboxbooleanrequiredTrue for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its
urlis the dashboard page that shows them, and its url and mode cannot be changed.createdbooleanrequiredTrue when this call made the inbox; false when the account already had one, which is answered as it is.
secretstring | nullrequiredThe signing secret, in the clear, on the call that made the inbox and never again (null on every later call). It is stored sealed and no read gives it back. Store it now; if you lose it, rotate.
meta
pageintegertotalintegerlimitintegerThe page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.
pagesintegertotaloverlimit, rounded up.next_cursorstring | nullOn a cursor-paged operation: send this back as
cursorfor the next page. Null on the last page. Absent on operations that do not page by cursor.has_morebooleanOn a list operation whose
metaisListMeta: whether another page follows this one.
{
"status": "success",
"data": {
"id": "whk_Pxpo7Z0TfZI3H2ZY7yS2tukP",
"url": "https://portal.printedsimply.com/dashboard/webhooks/inbox",
"events": [
"order.created",
"order.paid",
"order.in_production",
"order.shipped",
"order.delivered",
"order.cancelled",
"order.failed",
"order.updated",
"order.quality_check",
"order.issue",
"order.approval_requested",
"order.approval_approved",
"order.approval_declined",
"order.approval_expired",
"product.updated",
"catalog.synced",
"webhook.disabled"
],
"active": true,
"mode": "test",
"created_at": "2026-10-15T15:00:49.000Z",
"secret_rotated_at": null,
"previous_secret_expires_at": null,
"rotation_pending": false,
"inbox": true,
"created": true,
"secret": "95677446d44ce8482bae8540120310988cf90314cce8db3fed338d0918c5c296"
},
"request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}Errors
A live key (test_mode_only), or a field in the body (validation_error): the inbox takes none.
Missing, invalid or expired API key:
missing_api_key(none sent),invalid_api_key(unknown or revoked),key_expired(past the expiry set when it was created; create a new key).payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.
Rate limit exceeded. Two windows can produce this status and
error.codesays which:rate_limit_exceededfor the per-minute sliding window,daily_quota_exceededfor the plan's daily quota. Both carryRetry-After; the daily one also carriesX-Daily-LimitandX-Daily-Remaining. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.