anyshopSign in

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-15

Getting started

Two things to set up: a key to call us with, and an endpoint for us to call you back on.

  1. 1. Create an API key: Dashboard → API keys. It is shown once. Keys carry full access or a picked set of scopes; a call outside a key’s scopes returns 403 missing_scope.
  2. 2. Add a webhook endpoint: Dashboard → Webhooks. The signing secret is also shown once.
  3. 3. Watch an order arrive: open one of your payment links, complete a purchase, and watch order.paid land on your endpoint.
Your first call
curl https://anyshop.io/api/v1/products \
  -H "Authorization: Bearer ask_live_…"

Key scopes

ScopeGrants
products:readList and read products
products:writeCreate and update products
orders:readList and read orders
checkout:writeCreate checkout sessions
coupons:readList coupons
coupons:writeCreate coupons
payment_links:readList payment links
payment_links:writeCreate payment links
webhooks:readList webhook endpoints
webhooks:writeCreate and delete webhook endpoints
events:readRead the event stream

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.

FieldTypeNotes
webhook-idstringThe event id. Use it as your idempotency key.
webhook-timestampstringUnix seconds. Reject anything more than 5 minutes old.
webhook-signaturestringSpace-separated list of "v1,<base64>". Accept the delivery if ANY entry matches.
Verify a delivery (Node)
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.

EventFires whenWhat to do
order.paidA 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.refundedA refund is recorded, whether issued from anyshop or from the Stripe dashboard.Revoke if fully_refunded is true.
order.disputedA dispute is opened.Suspend access.
order.dispute_closedA dispute is won or lost.Reinstate if won.
subscription.createdA subscription activates, in the same transaction as its order.paid.Grant, and store the subscription id.
subscription.importedA migrated subscription is taken over. No charge happened.Link only — never re-provision.
subscription.import_revertedA takeover was aborted and the previous billing was reinstated.Do NOT revoke.
subscription.renewal_upcomingAbout 30 days before a renewal charge, once per cycle.Not emitted for PayPal-billed subscriptions — PayPal notifies buyers under its own rules.Optional — surface it to your customer, or ignore it.
subscription.renewedA renewal succeeded. Embeds the renewal order.Extend to current_period_end.
subscription.past_dueA renewal payment failed. Carries the dunning timeline.For PayPal-billed subscriptions the retry cadence is PayPal’s; the payload carries a payment_error instead of a timeline.Keep access; flag if you want to.
subscription.canceledA 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

FieldTypeNotes
idstringEvent id (evt_…). Also the webhook-id header — dedupe on it.
typestringOne of the event types above.
created_atstringISO-8601 UTC. The 72-hour retry window is measured from here.
dataobjectPer-type body. For order.* it is { order }.

The order object

FieldTypeNotes
objectstringAlways "order".
idstringord_… — stable, and the id to use with GET /v1/orders/{id}.
modestring"live" or "test". Test orders never involve real money.
statusstring"paid", "partially_refunded", or "refunded".
originstring"checkout" or "renewal". order.paid is always "checkout".
checkout_session_idstring | nullThe session that produced this order.
subscription_idstring | nullSet once subscriptions ship; null today.
currencystringISO-4217, uppercase. Every amount below is in this currency.
amount_subtotalintegerMinor units, before discount.
amount_discountintegerMinor units taken off by the coupon.
amount_taxintegerMinor units. Always 0 until the VAT pillar ships.
amount_totalintegerMinor units actually charged.
amount_refundedintegerMinor units refunded so far.
couponobject | null{ id, code } when a coupon applied, otherwise null.
buyer_countrystring | nullTwo-letter code as given at checkout.
customerobject{ id, email }. Email is null after an erasure request.
metadataobjectYour own key/values from the checkout session, echoed back.
itemsarrayLine items — see below. Always at least one.
paid_atstring | nullISO-8601 UTC.
created_atstring | nullISO-8601 UTC.

Line items

FieldTypeNotes
objectstringAlways "order_item".
idstringoli_…
product_idstringprod_… — the product as it exists now.
product_namestringThe name AT PURCHASE TIME. Renaming a product never rewrites it.
pricing_typestring"one_time" or "subscription".
quantityintegerUnits bought.
unit_amountintegerMinor units per unit, at purchase time.
subtotalintegerMinor units for this line.
currencystringISO-4217, uppercase.
order.paid
{
  "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.

5s5m30m2h5h10hthen every 6h
  • 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.

Digital delivery

What a purchase hands over (files and license keys) and how your entitlement logic should relate to it.

  • Buyers collect files and license keys from their receipt page in the portal. Entitlement is the order itself: while an order is paid (or partially refunded) and not disputed, its deliverables are reachable; a full refund or a dispute revokes access automatically, with no separate event.
  • License keys are claimed atomically when the order is paid: one buyer per key, guaranteed at the database. A product whose key pool is empty refuses new checkouts (buyers see “product not available”), so a paid order without a key is a rare race the merchant is alerted to.
  • Dynamic delivery: a product can carry a fulfillment URL. On every paid checkout, anyshop POSTs a signed JSON request there ({fulfillment_id, order_id, product_id, product_name, buyer_email, amount, currency, mode, metadata}), signed with the product’s own secret using the exact Standard Webhooks scheme described above. Whatever your endpoint returns with a 2xx (text, up to 64 KB) becomes the buyer’s deliverable, shown in their portal like a license key. Non-2xx and timeouts retry on the same published ladder; the buyer sees “being prepared” until then, and the merchant can retry a failed one from the order page.
  • For YOUR system’s entitlements, nothing changes: act on order.paid and order.refunded exactly as before. anyshop’s built-in delivery is additive: use it, ignore it, or do both.

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/productsList active products.
POST/v1/productsCreate a product.
GET/v1/products/{id}Fetch one product.
GET/v1/couponsList coupons.
POST/v1/couponsCreate a coupon.
GET/v1/payment-linksList payment links.
POST/v1/payment-linksCreate a payment link.
POST/v1/checkout-sessionsOpen a checkout session, carrying your own metadata through to the order and its webhooks.
GET/v1/ordersList orders, newest first.
GET/v1/orders/{id}Fetch one order — the same object order.paid delivers.
GET/v1/eventsRead the event log (30 days).
GET/v1/webhook-endpointsList endpoints.
POST/v1/webhook-endpointsCreate an endpoint. The secret is returned once.
GET/v1/webhook-endpoints/{id}Fetch one endpoint.
DELETE/v1/webhook-endpoints/{id}Disable an endpoint.

Errors

401unauthorizedMissing, malformed, unknown, or revoked key.
400invalid_requestThe body failed validation. Field errors are included.
404not_foundNo such resource in your store.
405method_not_allowedWrong verb for that path.
409conflictThe resource is in a state that refuses this change.
429rate_limitedSlow down. Retry-After tells you how long.
500internal_errorOur fault. Safe to retry an idempotent call.
Error shape
{
  "error": {
    "type": "invalid_request",
    "message": "Check the fields and try again.",
    "fields": [{ "path": "price_amount", "message": "Expected number" }]
  }
}

PayPal

Stores can take PayPal next to cards. For your integration, nothing changes; here are the edges.

Orders are identical on the wire
order.paid, order.refunded, and every /v1 read look the same whichever processor charged. Keep keying on customer.id and deduplicating on the event id — nothing in your handler needs a processor branch.
Refund and dispute payloads carry both id families
Refunds and disputes on PayPal orders carry paypal_refund_id / paypal_dispute_id, and stripe_refund_id / stripe_dispute_id are null (and vice versa). Treat the ids as opaque evidence — the amounts and statuses are the contract.
PayPal subscriptions are billed by PayPal
The subscription wire object says source: "paypal". Renewals still arrive as subscription.renewed with an embedded order; payment failures arrive as subscription.past_due with a payment_error instead of a retry timeline (the retry cadence is PayPal’s); subscription.renewal_upcoming is not emitted for these.
Buyer consent is collected on the product page
For PayPal checkouts the EU withdrawal waiver is a checkbox on the storefront (PayPal’s hosted page collects no consent). The accepted text still lands on the order as withdrawal_waiver_text.

Migrating from sell.app

If you are moving an integration across, this is the whole diff.

sell.appanyshopNotes
order.completedorder.paidRenamed. Renewals are excluded — they arrive as subscription.renewed once subscriptions ship.
subscription.cancelledsubscription.canceledOne "l". American spelling throughout.
Hex HMAC of the body, one secret per storeStandard Webhooks, one secret per endpointThe signature covers "{id}.{timestamp}.{body}", not the body alone. About twenty lines to swap.
Key on customer emailKey on customer.idEmail 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, subscription.renewal_upcomingNet-new events with no sell.app equivalent. Ignore them safely until you handle them.