Integration docs
Build against anyshop
One webhook to grant access, one REST surface to manage what you sell. Money is always an integer in minor units, ids always carry their prefix, and a resource that is not yours is a 404 rather than an admission that it exists.
Last updated 2026-08-12
Getting started
Two things to set up: a key to call us with, and an endpoint for us to call you back on.
- 1. Create an API key — Dashboard → API keys. It is shown once. Test and live keys are separate; the key decides which world you are in.
- 2. Add a webhook endpoint — Dashboard → Webhooks. The signing secret is also shown once.
- 3. Buy something in test mode — seed a product, open its payment link, pay with 4242 4242 4242 4242, and watch order.paid arrive.
curl https://anyshop.io/api/v1/products \
-H "Authorization: Bearer ask_test_…"Webhooks
Deliveries are signed with Standard Webhooks. Verify every one before you act on it — the signature is what distinguishes us from anyone who learned your endpoint URL.
| Field | Type | Notes |
|---|---|---|
| webhook-id | string | The event id. Use it as your idempotency key. |
| webhook-timestamp | string | Unix seconds. Reject anything more than 5 minutes old. |
| webhook-signature | string | Space-separated list of "v1,<base64>". Accept the delivery if ANY entry matches. |
const crypto = require('node:crypto');
// secret: the whsec_… value shown once when you created the endpoint
function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader) return false;
// Reject replays: more than five minutes of clock skew is not skew.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');
const expected =
'v1,' + crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
// Several signatures arrive during a secret rotation — any match is valid.
return signatureHeader.split(' ').some((candidate) => {
const a = Buffer.from(candidate);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}Sign over the raw request body, byte for byte — parsing and re-serializing JSON changes it and the signature will not match. During a secret rotation the header carries several signatures for 24 hours; accept the delivery if any of them verifies.
Event catalog
What fires, and what an entitlement system should do about it. Events marked planned are part of the ratified taxonomy but are not emitted yet — subscribing to one today is valid and simply delivers nothing.
| Event | Fires when | What to do |
|---|---|---|
| order.paid | A checkout order is paid — one-time, or the first order of a subscription.Never fires for renewals; those arrive as subscription.renewed. | Grant access. |
| order.refundedplanned | A refund is recorded, whether issued from anyshop or from the Stripe dashboard. | Revoke if fully_refunded is true. |
| order.disputedplanned | A dispute is opened. | Suspend access. |
| order.dispute_closedplanned | A dispute is won or lost. | Reinstate if won. |
| subscription.createdplanned | A subscription activates, in the same transaction as its order.paid. | Grant, and store the subscription id. |
| subscription.importedplanned | A migrated subscription is taken over. No charge happened. | Link only — never re-provision. |
| subscription.import_revertedplanned | A takeover was aborted and the previous billing was reinstated. | Do NOT revoke. |
| subscription.renewedplanned | A renewal succeeded. Embeds the renewal order. | Extend to current_period_end. |
| subscription.past_dueplanned | A renewal payment failed. Carries the dunning timeline. | Keep access; flag if you want to. |
| subscription.canceledplanned | A subscription actually ended, with a reason. | Revoke. |
Payloads are snapshots taken when the event was created, and deliveries are not ordered. Treat the event as a signal, and read the resource back through the API when you need authoritative state.
Payload reference
Every delivery has the same envelope. The order object below is byte-identical to what GET /v1/orders/{id} returns — one shape, two ways to reach it.
Envelope
| Field | Type | Notes |
|---|---|---|
| id | string | Event id (evt_…). Also the webhook-id header — dedupe on it. |
| type | string | One of the event types above. |
| created_at | string | ISO-8601 UTC. The 72-hour retry window is measured from here. |
| data | object | Per-type body. For order.* it is { order }. |
The order object
| Field | Type | Notes |
|---|---|---|
| object | string | Always "order". |
| id | string | ord_… — stable, and the id to use with GET /v1/orders/{id}. |
| mode | string | "live" or "test". Test orders never involve real money. |
| status | string | "paid", "partially_refunded", or "refunded". |
| origin | string | "checkout" or "renewal". order.paid is always "checkout". |
| checkout_session_id | string | null | The session that produced this order. |
| subscription_id | string | null | Set once subscriptions ship; null today. |
| currency | string | ISO-4217, uppercase. Every amount below is in this currency. |
| amount_subtotal | integer | Minor units, before discount. |
| amount_discount | integer | Minor units taken off by the coupon. |
| amount_tax | integer | Minor units. Always 0 until the VAT pillar ships. |
| amount_total | integer | Minor units actually charged. |
| amount_refunded | integer | Minor units refunded so far. |
| coupon | object | null | { id, code } when a coupon applied, otherwise null. |
| buyer_country | string | null | Two-letter code as given at checkout. |
| customer | object | { id, email }. Email is null after an erasure request. |
| metadata | object | Your own key/values from the checkout session, echoed back. |
| items | array | Line items — see below. Always at least one. |
| paid_at | string | null | ISO-8601 UTC. |
| created_at | string | null | ISO-8601 UTC. |
Line items
| Field | Type | Notes |
|---|---|---|
| object | string | Always "order_item". |
| id | string | oli_… |
| product_id | string | prod_… — the product as it exists now. |
| product_name | string | The name AT PURCHASE TIME. Renaming a product never rewrites it. |
| pricing_type | string | "one_time" or "subscription". |
| quantity | integer | Units bought. |
| unit_amount | integer | Minor units per unit, at purchase time. |
| subtotal | integer | Minor units for this line. |
| currency | string | ISO-4217, uppercase. |
{
"id": "evt_01kzsfzevmet5t2x52tdmrkths",
"type": "order.paid",
"created_at": "2026-08-12T09:15:22.000Z",
"data": {
"order": {
"object": "order",
"id": "ord_01kzsfzevnenhvmje0gaq3t9kr",
"mode": "live",
"status": "paid",
"origin": "checkout",
"checkout_session_id": "cs_01kzsfzevnecdrxee8gpjptt3a",
"subscription_id": null,
"currency": "EUR",
"amount_subtotal": 4900,
"amount_discount": 490,
"amount_tax": 0,
"amount_total": 4410,
"amount_refunded": 0,
"coupon": {
"id": "cpn_01kzsfzevnejkb0dcgxc4e0ypd",
"code": "LAUNCH10"
},
"buyer_country": "DE",
"customer": {
"id": "cust_01kzsfzevne699t1xd2gd4qh0k",
"email": "buyer@example.com"
},
"metadata": {
"your_user_id": "84213",
"plan": "pro"
},
"items": [
{
"object": "order_item",
"id": "oli_01kzsfzevnfzbve1kvyders7dj",
"product_id": "prod_01kzsfzevnfh18rfjejnr4w3dy",
"product_name": "Pro plan — 1 year",
"pricing_type": "one_time",
"quantity": 1,
"unit_amount": 4900,
"subtotal": 4900,
"currency": "EUR"
}
],
"paid_at": "2026-08-12T09:15:22.000Z",
"created_at": "2026-08-12T09:15:20.000Z"
}
}
}Delivery & retries
These numbers are the contract, and they are read from the same constants the delivery worker runs on.
- Delivery stops at 16 attempts or 72 hours after the event was created, whichever comes first.
- The clock is anchored to the EVENT, not to the delivery: redriving an old event does not restart it.
- Delays are lower bounds. A retry may land later than the schedule, never earlier.
- Respond 2xx within 20 seconds. Do the work asynchronously — a slow 200 counts as a timeout.
- An endpoint that exhausts a delivery having had zero successes since that event was created is disabled automatically, and we email the support address on your business profile.
- A manual redrive from the dashboard makes exactly one attempt and never retries.
REST API
Base URL https://anyshop.io/api/v1. JSON in, JSON out.
- Authenticate with `Authorization: Bearer ask_live_…` (or `ask_test_…`). The key determines both the store and the mode — there is no store parameter.
- A resource that belongs to another store returns 404, never 403. "That exists but is not yours" is an existence oracle, so we do not say it.
- Money is always an integer in minor units next to its currency. Never a float.
- Timestamps are ISO-8601 UTC. Ids carry their prefix (ord_, prod_, cust_…).
- Fields are added, never removed or retyped. A breaking change would come with a new version.
- 120 requests per minute per store; failed authentications are limited separately, by IP.
Endpoints
| GET | /v1/products | List active products. |
| POST | /v1/products | Create a product. |
| GET | /v1/products/{id} | Fetch one product. |
| GET | /v1/coupons | List coupons. |
| POST | /v1/coupons | Create a coupon. |
| GET | /v1/payment-links | List payment links. |
| POST | /v1/payment-links | Create a payment link. |
| POST | /v1/checkout-sessions | Open a checkout session, carrying your own metadata through to the order and its webhooks. |
| GET | /v1/orders | List orders, newest first. |
| GET | /v1/orders/{id} | Fetch one order — the same object order.paid delivers. |
| GET | /v1/events | Read the event log (30 days). |
| GET | /v1/webhook-endpoints | List endpoints. |
| POST | /v1/webhook-endpoints | Create an endpoint. The secret is returned once. |
| GET | /v1/webhook-endpoints/{id} | Fetch one endpoint. |
| DELETE | /v1/webhook-endpoints/{id} | Disable an endpoint. |
Errors
| 401 | unauthorized | Missing, malformed, unknown, or revoked key. |
| 400 | invalid_request | The body failed validation. Field errors are included. |
| 404 | not_found | No such resource in your store. |
| 405 | method_not_allowed | Wrong verb for that path. |
| 409 | conflict | The resource is in a state that refuses this change. |
| 429 | rate_limited | Slow down. Retry-After tells you how long. |
| 500 | internal_error | Our fault. Safe to retry an idempotent call. |
{
"error": {
"type": "invalid_request",
"message": "Check the fields and try again.",
"fields": [{ "path": "price_amount", "message": "Expected number" }]
}
}Migrating from sell.app
If you are moving an integration across, this is the whole diff.
| sell.app | anyshop | Notes |
|---|---|---|
| order.completed | order.paid | Renamed. Renewals are excluded — they arrive as subscription.renewed once subscriptions ship. |
| subscription.cancelled | subscription.canceled | One "l". American spelling throughout. |
| Hex HMAC of the body, one secret per store | Standard Webhooks, one secret per endpoint | The signature covers "{id}.{timestamp}.{body}", not the body alone. About twenty lines to swap. |
| Key on customer email | Key on customer.id | Email is still in the payload, but it changes; the id does not, and it is null-safe after an erasure. |
| — | order.refunded, subscription.past_due, subscription.imported | Net-new events with no sell.app equivalent. Ignore them safely until you handle them. |