Store API
Webhooks
Your systems hear about a paid order, low stock or a return the moment it happens, as a signed JSON request to your URL, so nothing has to poll the API.
POST /webhooks/tanqory HTTP/1.1Host: example.comContent-Type: application/jsonX-Tanqory-Webhook-Id: wh-example-7f3aX-Tanqory-Signature: t=1791561600,v1=1ca6cc7691673eabb05fd1a4dd844af79937c8540138c0981cde3c68c3537ae6X-Tanqory-Event: order_paymentX-Tanqory-Topic: orders/paidUser-Agent: Tanqory-Webhook/1.0{ "id": "evt-example-1", "event": "order_payment", "apiVersion": "2025-01", "createdAt": "2026-10-09T09:20:00.000Z", "storeId": "store-example-01", "idempotencyKey": "order.paid:ord-example-1042", "data": { "storeId": "store-example-01", "orderId": "ord-example-1042", "orderNumber": 1042, "customerId": "cus-example-77", "timestamp": "2026-10-09T09:20:00.000Z" }}POST /webhooks/tanqory HTTP/1.1Host: example.comContent-Type: application/jsonX-Tanqory-Webhook-Id: wh-example-7f3aX-Tanqory-Signature: t=1791561600,v1=5471fcd399f9792d2ab3cdc3a281480aca9e5fe108d04aff40a497829e8528d2X-Tanqory-Event: inventory_levels/low_stockX-Tanqory-Topic: inventory_levels/low_stockUser-Agent: Tanqory-Webhook/1.0{ "id": "evt-example-2", "event": "inventory_levels/low_stock", "apiVersion": "2025-01", "createdAt": "2026-10-09T09:20:00.000Z", "storeId": "store-example-01", "idempotencyKey": "inventory.low:prd-example-318:row-example-5521", "data": { "storeId": "store-example-01", "productId": "prd-example-318" }}POST /webhooks/tanqory HTTP/1.1Host: example.comContent-Type: application/jsonX-Tanqory-Webhook-Id: wh-example-7f3aX-Tanqory-Signature: t=1791561600,v1=e7f2b753e4c03ae327e9a0f683fffb2578a061b640c8ec3205c6f003e3a32fd6X-Tanqory-Event: returns/requestedX-Tanqory-Topic: returns/requestedUser-Agent: Tanqory-Webhook/1.0{ "id": "evt-example-3", "event": "returns/requested", "apiVersion": "2025-01", "createdAt": "2026-10-09T09:20:00.000Z", "storeId": "store-example-01", "idempotencyKey": "return.requested:ret-example-64", "data": { "storeId": "store-example-01", "returnId": "ret-example-64", "orderId": "ord-example-1042", "order": { "id": "ord-example-1042", "orderNumber": 1042 }, "refundReason": "Wrong size", "timestamp": "2026-10-09T09:20:00.000Z" }}Example values: ids, times and amounts are made up. Header names, field names and the signature format are exact, and the signature is real for the example secret.
Receive
Handle a delivery
A delivery is a POST with a JSON body. Read the raw body, check the signature, skip a key you have already handled, answer 2xx at once and do the work after.
- 2xx within 30 secondsAny status from 200 to 299 inside 30 seconds counts as delivered. Anything else is a failed attempt.
- Not in orderEach delivery travels on its own with its own retries, so a later event can arrive first. Read createdAt, or fetch the current state from the API.
- Dedupe on idempotencyKeyThe key comes from the business fact, such as order.paid:<orderId>, so the same fact keeps the same key on every retry.
const express = require('express')const { verifyTanqory } = require('./verify')const app = express()const seen = new Set() // keep handled keys in your database in production// express.raw keeps the exact bytes the signature coversapp.post('/webhooks/tanqory', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyTanqory(req.body, req.get('X-Tanqory-Signature'), process.env.TANQORY_WEBHOOK_SECRET) if (!ok) return res.sendStatus(400) const event = JSON.parse(req.body) if (seen.has(event.idempotencyKey)) return res.sendStatus(200) // already handled seen.add(event.idempotencyKey) res.sendStatus(200) // answer first: 2xx within 30 seconds setImmediate(() => handle(req.get('X-Tanqory-Topic'), event)) // then do the work})app.listen(3000)import jsonimport osimport queuefrom flask import Flask, requestfrom verify import verify_tanqoryapp = Flask(__name__)jobs = queue.Queue() # a worker drains this; use your job queue in productionseen = set() # keep handled keys in your database in production@app.post("/webhooks/tanqory")def tanqory_webhook(): raw = request.get_data() # the exact bytes the signature covers header = request.headers.get("X-Tanqory-Signature", "") if not verify_tanqory(raw, header, os.environ["TANQORY_WEBHOOK_SECRET"]): return "", 400 event = json.loads(raw) if event["idempotencyKey"] in seen: return "", 200 # already handled seen.add(event["idempotencyKey"]) jobs.put((request.headers.get("X-Tanqory-Topic"), event)) # work after return "", 200 # 2xx within 30 secondsSubscribe
Create a subscription
One subscription is one topic sent to one URL. Create it with REST or GraphQL, and keep the secret the answer returns: it signs every delivery to that URL.
- One topic per subscriptionSend event as a slash topic such as orders/paid. For more topics on the same URL, create one subscription each.
- Owners and adminsCreating, changing, testing and deleting need the store owner or an admin with the manage webhooks permission. Reading needs view access.
- A secret per subscriptionThe server makes a 64 character hex secret for every subscription. GraphQL returns it once, on create.
- No duplicatesThe same URL and topic twice in one store answers 409. Use https for your URL so the body is encrypted in transit.
- Or ask the assistantThe Tanqory AI assistant can list, create, test and delete a store's webhooks with your permissions, and never shows the secret, so setup takes a sentence instead of a ticket.
# The Store API answers on api.tanqory.com for every storecurl -X POST https://api.tanqory.com/v1/stores/$STORE_ID/notifications/webhooks \ -H "Authorization: Bearer $TANQORY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "event": "orders/paid", "format": "JSON", "url": "https://example.com/webhooks/tanqory", "apiVersion": "2025-01" }'mutation { webhookSubscriptionCreate( input: { url: "https://example.com/webhooks/tanqory" topics: ["orders/paid"] apiVersion: "2025-01" } ) { subscription { id topics status } userErrors { field message } secret # returned once: store it now }}Topics
Topics
Every topic a store can subscribe to, grouped by resource, with the fields its data carries. The dedupe key tells you whether one key means one fact or one occurrence. A fact key repeats only for the same fact; an occurrence key adds the row of each occurrence, so two stock changes on one item get two keys.
orders12
orders/createLegacy nameorder_creationKey: <event>:<id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/updatedLegacy nameorder_updatedKey: <event>:<id>:<row id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/paidLegacy nameorder_paymentKey: <event>:<id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/fulfilledLegacy nameorder_fulfilledKey: <event>:<id>13 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptionalfulfillmentIdstringoptionaltrackingNumberstringoptionaltrackingUrlstringoptionaltrackingCompanystringoptionalshippedAtstringoptional
orders/partially_fulfilledKey: <event>:<id>:<row id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/cancelledLegacy nameorder_cancelledKey: <event>:<id>12 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptionalrefundAmountnumberoptionalrefundReasonstringoptionalcancelReasonstringoptionalcancelledAtstringoptional
orders/tags_addedKey: <event>:<id>:<row id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/tags_removedKey: <event>:<id>:<row id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/notes_addedKey: <event>:<id>:<row id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/risk_updatedLegacy nameorder_risk_assessment_changedKey: <event>:<id>:<row id>8 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptional
orders/editedKey: <event>:<id>:<row id>9 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectidstringorderNumberstring | numberoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringorderEditIdstring
orders/deletedKey: <event>:<id>9 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectidstringorderNumberstring | numberoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringdeletedAtstring
refunds1
refunds/createLegacy namerefund_creationKey: <event>:<id>:<row id>15 fieldsstoreIdstringorderIdstringorderNumberstring | numberoptionalcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptionaltotalPricestring | numberoptionalcurrencystringoptional
orderDataobjectoptionalcurrencystringoptionaltotalAmountnumberoptionalsubtotalAmountnumberoptionaltaxAmountnumberoptionalshippingAmountnumberoptionaldiscountAmountnumberoptionalcustomerEmailstringoptionalcustomerFirstNamestringoptionalcustomerLastNamestringoptionalpaymentMethodstringoptionalstatusUrlstringoptionallineItemsmap[]optionalshippingAddressFullmapoptionalbillingAddressFullmapoptional
timestampstringoptionalrefundAmountnumberoptionalrefundReasonstringoptionalrefundedSubtotalnumberoptionalrefundedTaxnumberoptionalrefundedShippingnumberoptionalrefundedLineItemsmap[]optionalrefundedAtstringoptional
draft_orders3
draft_orders/createKey: <event>:<id>7 fieldsstoreIdstringdraftOrderIdstringdraftOrderobjectidstringnamestringstatusstringtotalPricenumbercurrencystringcustomerIdstringoptional
timestampstringcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptionalfirstNamestringoptionallastNamestringoptional
invoiceUrlstringoptional
draft_orders/deletedKey: <event>:<id>4 fieldsstoreIdstringdraftOrderIdstringdraftOrderobjectidstringnamestringstatusstringtotalPricenumbercurrencystringcustomerIdstringoptional
timestampstring
draft_orders/updatedKey: <event>:<id>:<row id>4 fieldsstoreIdstringdraftOrderIdstringdraftOrderobjectidstringnamestringstatusstringtotalPricenumbercurrencystringcustomerIdstringoptional
timestampstring
fulfillments3
fulfillments/createKey: <event>:<id>11 fieldsstoreIdstringfulfillmentIdstringfulfillmentobjectidstring
orderIdstringorderobjectidstringorderNumberstring | number
customerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptional
trackingNumberstringoptionaltrackingUrlstringoptionaltrackingCompanystringoptionaltimestampstring
fulfillments/tracking_addedKey: <event>:<id>:<row id>10 fieldsstoreIdstringfulfillmentIdstringorderIdstringoptionalorderobjectoptionalidstringoptionalorderNumberstring | numberoptional
customerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptionalfirstNamestringoptionallastNamestringoptional
trackingNumberstringoptionaltrackingUrlstringoptionaltrackingCompanystringoptionaltimestampstringoptional
fulfillments/deliveredKey: <event>:<id>11 fieldsstoreIdstringfulfillmentIdstringorderIdstringoptionalorderobjectoptionalidstringoptionalorderNumberstring | numberoptional
customerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptionalfirstNamestringoptionallastNamestringoptional
trackingNumberstringoptionaltrackingUrlstringoptionaltrackingCompanystringoptionaldeliveredAtstringoptionaltimestampstringoptional
fulfillment_events1
fulfillment_events/createKey: <event>:<id>:<row id>13 fieldsstoreIdstringfulfillmentIdstringorderIdstringoptionalorderobjectoptionalidstringoptionalorderNumberstring | numberoptional
customerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptionalfirstNamestringoptionallastNamestringoptional
trackingNumberstringoptionaltrackingUrlstringoptionaltrackingCompanystringoptionaltimestampstringoptionalstatusstringoptionalestimatedDeliverystringoptionalhappenedAtstringoptional
returns4
returns/requestedKey: <event>:<id>8 fieldsstoreIdstringreturnIdstringorderIdstringorderobjectidstringorderNumberstring | numberoptionalstatusUrlstringoptional
customerIdstringoptionalrefundReasonstringoptionalcustomerobjectoptionalidstringemailstringoptionalfirstNamestringoptionallastNamestringoptional
timestampstring
returns/approvedKey: <event>:<id>8 fieldsstoreIdstringreturnIdstringorderIdstringorderobjectidstringorderNumberstring | numberoptionalstatusUrlstringoptional
customerIdstringoptionalrefundReasonstringoptionalcustomerobjectoptionalidstringemailstringoptionalfirstNamestringoptionallastNamestringoptional
timestampstring
returns/declinedKey: <event>:<id>:<row id>9 fieldsstoreIdstringreturnIdstringorderIdstringorderobjectidstringorderNumberstring | numberoptionalstatusUrlstringoptional
customerIdstringoptionalrefundReasonstringoptionalcustomerobjectoptionalidstringemailstringoptionalfirstNamestringoptionallastNamestringoptional
timestampstringdeclineReasonstringoptional
returns/closedKey: <event>:<id>:<row id>9 fieldsstoreIdstringreturnIdstringorderIdstringorderobjectidstringorderNumberstring | numberoptionalstatusUrlstringoptional
customerIdstringoptionalrefundReasonstringoptionalcustomerobjectoptionalidstringemailstringoptionalfirstNamestringoptionallastNamestringoptional
timestampstringclosedAtstringoptional
products4
products/createLegacy nameproduct_creationKey: <event>:<id>2 fieldsstoreIdstringproductIdstring
products/updateLegacy nameproduct_updateKey: <event>:<id>:<row id>2 fieldsstoreIdstringproductIdstring
products/deleteLegacy nameproduct_deletionKey: <event>:<id>2 fieldsstoreIdstringproductIdstring
products/publishedKey: <event>:<id>:<row id>6 fieldsstoreIdstringproductIdstringproductobjectidstring
channelsstring[]optionalsalesChannelstringoptionaltimestampstring
collections3
collections/createKey: <event>:<id>4 fieldsstoreIdstringcollectionIdstringcollectionobjectidstringtitlestringhandlestring
timestampstring
collections/updateKey: <event>:<id>:<row id>4 fieldsstoreIdstringcollectionIdstringcollectionobjectidstringtitlestringhandlestring
timestampstring
collections/deleteKey: <event>:<id>4 fieldsstoreIdstringcollectionIdstringcollectionobjectidstringtitlestringhandlestring
timestampstring
inventory_levels4
inventory_levels/updateKey: <event>:<id>:<row id>2 fieldsstoreIdstringproductIdstring
inventory_levels/low_stockKey: <event>:<id>:<row id>2 fieldsstoreIdstringproductIdstring
inventory_levels/out_of_stockKey: <event>:<id>:<row id>2 fieldsstoreIdstringproductIdstring
inventory_levels/restockedKey: <event>:<id>:<row id>2 fieldsstoreIdstringproductIdstring
customers8
customers/createLegacy namecustomer_creationKey: <event>:<id>4 fieldsstoreIdstringcustomerIdstringcustomerobjectoptionalidstringoptionalemailstringoptionalfirstNamestringoptionallastNamestringoptional
timestampstringoptional
customers/updateLegacy namecustomer_updateKey: <event>:<id>:<row id>2 fieldsstoreIdstringcustomerIdstring
customers/deleteKey: <event>:<id>2 fieldsstoreIdstringcustomerIdstring
customers/enableKey: <event>:<id>2 fieldsstoreIdstringcustomerIdstring
customers/taggedKey: <event>:<id>:<row id>8 fieldsstoreIdstringcustomerIdstringcustomerobjectidstringemailstringfirstNamestringoptionallastNamestringoptional
tagsstring[]actionenumone of added, removedaddedTagsstring[]optionalremovedTagsstring[]optionaltimestampstring
customers/marketing_consent_updatedKey: <event>:<id>:<row id>2 fieldsstoreIdstringcustomerIdstring
customers/redactApps onlyKey: <event>:<id>:<row id>5 fieldsstoreIdstringcustomerIdstringcustomerobjectidstring
ordersRedactedintegeroptionaltimestampstring
customers/disableKey: <event>:<id>:<row id>2 fieldsstoreIdstringcustomerIdstring
checkouts2
checkouts/createLegacy namecheckout_creationKey: <event>:<id>2 fieldsstoreIdstringcheckoutIdstring
checkouts/updateKey: <event>:<id>:<row id>2 fieldsstoreIdstringcheckoutIdstring
carts4
carts/abandonedKey: <event>:<id>3 fieldsstoreIdstringcartIdstringcheckoutIdstring
carts/recoveredKey: <event>:<id>3 fieldsstoreIdstringcartIdstringcheckoutIdstring
carts/createKey: <event>:<id>5 fieldsstoreIdstringcartIdstringcheckoutIdstringcartobjectidstringtokenstringoptional
timestampstring
carts/updateKey: <event>:<id>:<row id>5 fieldsstoreIdstringcartIdstringcheckoutIdstringlineItemsobjectoptionalcountintegeroptionalsubtotalnumberoptionalcurrencystringoptional
timestampstring
companies8
companies/createKey: <event>:<id>4 fieldsstoreIdstringcompanyIdstringcompanyobjectidstringnamestringexternalIdstring
timestampstring
companies/updateKey: <event>:<id>:<row id>5 fieldsstoreIdstringcompanyIdstringcompanyobjectidstringnamestringexternalIdstring
timestampstringchangedFieldsstring[]
companies/deleteKey: <event>:<id>4 fieldsstoreIdstringcompanyIdstringcompanyobjectidstringnamestringexternalIdstring
timestampstring
companies/location_createdKey: <event>:<id>:<row id>5 fieldsstoreIdstringcompanyIdstringcompanyLocationIdstringlocationobjectidstringnamestringshippingAddressmapisPrimaryboolean
timestampstring
companies/location_updatedKey: <event>:<id>:<row id>5 fieldsstoreIdstringcompanyIdstringcompanyLocationIdstringlocationobjectidstringnamestringshippingAddressmapisPrimaryboolean
timestampstring
companies/location_deletedKey: <event>:<id>:<row id>5 fieldsstoreIdstringcompanyIdstringcompanyLocationIdstringlocationobjectidstringnamestringshippingAddressmap
timestampstring
companies/contact_addedKey: <event>:<id>:<row id>8 fieldsstoreIdstringcompanyIdstringcompanyLocationIdstringcompanyContactIdstringcustomerIdstringcontactobjectidstringcustomerIdstringemailstringoptionalrolestring
timestampstringgrantedboolean
companies/contact_removedKey: <event>:<id>:<row id>7 fieldsstoreIdstringcompanyIdstringcompanyLocationIdstringcompanyContactIdstringcustomerIdstringcontactobjectidstringcustomerIdstringemailstringoptionalrolestring
timestampstring
app1
app/uninstalledLegacy nameapp_uninstalledApps onlyKey: <event>:<id>3 fieldsstoreIdstringinstallationIdstringoccurredAtdatetime
bulk_jobs4
bulk_jobs/createKey: <event>:<id>8 fieldsstoreIdstringjobIdstringentitystringoperationenumone of EXPORT, IMPORTmodestringoptionalcreatedBystringoptionalactorTypeenumoptionalone of USER, APP, SYSTEMcreatedAtstringoptional
bulk_jobs/completeKey: <event>:<id>11 fieldsstoreIdstringjobIdstringentitystringoperationenumone of EXPORT, IMPORTmodestringoptionalstatusliteralone of COMPLETEDoutcomeenumone of OK, PARTIALcountsobjecttotalintegerprocessedintegercreatedintegerupdatedintegerdeletedintegerskippedintegerconflictedintegeroptionalfailedintegerunprocessedintegerdeletedByReplaceinteger
issuesSummaryobject[]codestringseverityenumone of ERROR, WARNING, INFOcountinteger
startedAtstringoptionalcompletedAtstringoptional
bulk_jobs/failedKey: <event>:<id>11 fieldsstoreIdstringjobIdstringentitystringoperationenumone of EXPORT, IMPORTmodestringoptionalstatusliteralone of FAILEDoutcomeliteralone of ERRORerrorCodestringerrorMessagestringcountsobjecttotalintegerprocessedintegercreatedintegerupdatedintegerdeletedintegerskippedintegerconflictedintegeroptionalfailedintegerunprocessedintegerdeletedByReplaceinteger
completedAtstringoptional
bulk_jobs/cancelledKey: <event>:<id>8 fieldsstoreIdstringjobIdstringentitystringoperationenumone of EXPORT, IMPORTmodestringoptionalstatusliteralone of CANCELLEDcountsobjecttotalintegerprocessedintegercreatedintegerupdatedintegerdeletedintegerskippedintegerconflictedintegeroptionalfailedintegerunprocessedintegerdeletedByReplaceinteger
cancelledAtstringoptional
marketing3
marketing/workflow_failedKey: <event>:<id>:<row id>7 fieldsstoreIdstringworkflowIdstringworkflowobjectidstringnamestringoptional
errorobjectmessagestring
sourceTriggerstringoptionalexecutionIdstringfailedAtstring
marketing/campaign_sentKey: <event>:<id>5 fieldsstoreIdstringcampaignIdstringcampaignobjectidstringnamestringoptionalsubjectstringoptional
recipientCountintegeroptionaltimestampstring
marketing/campaign_completedKey: <event>:<id>:<row id>6 fieldsstoreIdstringcampaignIdstringcampaignobjectidstring
sentCountintegeroptionalfailedCountintegeroptionaltimestampstring
store1
store/redactApps onlyKey: <event>:<id>4 fieldsstoreIdstringappHandlestringuninstalledAtstringtimestampstring
transactions1
transactions/createKey: <event>:<id>5 fieldsstoreIdstringorderIdstringtransactionIdstringtransactionobjectidstringkindstringstatusstringamountnumbercurrencystringgatewaystringoptionalpaymentMethodstringoptional
timestampstring
locations3
locations/createKey: <event>:<id>4 fieldsstoreIdstringlocationIdstringlocationobjectidstringnamestringisDefaultbooleanoptionalisActivebooleanoptional
timestampstring
locations/updateKey: <event>:<id>:<row id>4 fieldsstoreIdstringlocationIdstringlocationobjectidstringnamestringisDefaultbooleanoptionalisActivebooleanoptional
timestampstring
locations/deleteKey: <event>:<id>4 fieldsstoreIdstringlocationIdstringlocationobjectidstringnamestringisDefaultbooleanoptionalisActivebooleanoptional
timestampstring
segments3
segments/createKey: <event>:<id>4 fieldsstoreIdstringsegmentIdstringsegmentobjectidstringnamestringcustomerCountintegeroptional
timestampstring
segments/updateKey: <event>:<id>:<row id>4 fieldsstoreIdstringsegmentIdstringsegmentobjectidstringnamestringcustomerCountintegeroptional
timestampstring
segments/deleteKey: <event>:<id>4 fieldsstoreIdstringsegmentIdstringsegmentobjectidstringnamestringcustomerCountintegeroptional
timestampstring
discounts3
discounts/createKey: <event>:<id>4 fieldsstoreIdstringdiscountIdstringdiscountobjectidstringcodestringoptionaltitlestringoptionaltypestringoptionalstatusstringoptional
timestampstring
discounts/updateKey: <event>:<id>:<row id>4 fieldsstoreIdstringdiscountIdstringdiscountobjectidstringcodestringoptionaltitlestringoptionaltypestringoptionalstatusstringoptional
timestampstring
discounts/deleteKey: <event>:<id>4 fieldsstoreIdstringdiscountIdstringdiscountobjectidstringcodestringoptionaltitlestringoptionaltypestringoptionalstatusstringoptional
timestampstring
gift_cards3
gift_cards/createKey: <event>:<id>8 fieldsstoreIdstringgiftCardIdstringgiftCardobjectidstringmaskedCodestringoptionalcurrencystringoptionalinitialValuenumberoptionalcurrentBalancenumberoptional
timestampstringcustomerIdstringoptionalcustomerobjectoptionalidstringoptionalemailstringoptionalfirstNamestringoptionallastNamestringoptional
recipientEmailstringoptionalmessagestringoptional
gift_cards/sentKey: <event>:<id>:<row id>7 fieldsstoreIdstringgiftCardIdstringgiftCardobjectidstringmaskedCodestringoptionalcurrencystringoptionalinitialValuenumberoptionalcurrentBalancenumberoptional
timestampstringrecipientEmailstringoptionalpurchaserobjectoptionalcustomerIdstringoptionalemailstringoptionalnamestringoptional
orderobjectoptionalidstringoptionalorderNumberstring | numberoptional
gift_cards/usedKey: <event>:<id>:<row id>7 fieldsstoreIdstringgiftCardIdstringgiftCardobjectidstringmaskedCodestringoptionalcurrencystringoptionalinitialValuenumberoptionalcurrentBalancenumberoptional
timestampstringamountRedeemednumberoptionalnewBalancenumberoptionalorderIdstringoptional
subscriptions3
subscriptions/createKey: <event>:<id>4 fieldsstoreIdstringsubscriptionContractIdstringsubscriptionobjectidstringcustomerIdstringoptionalstatusstringoptionalnextBillingDatestringoptional
timestampstring
subscriptions/cancelledKey: <event>:<id>:<row id>5 fieldsstoreIdstringsubscriptionContractIdstringsubscriptionobjectidstringcustomerIdstringoptionalstatusstringoptionalnextBillingDatestringoptional
timestampstringcancelReasonstringoptional
subscriptions/billing_failedKey: <event>:<id>:<row id>7 fieldsstoreIdstringsubscriptionContractIdstringsubscriptionobjectidstring
errorMessagestringoptionalwillRetrybooleanoptionalretryCountintegeroptionaltimestampstring
Delivery
What we send
Every delivery is a POST with the same headers and the same envelope. The topic travels in X-Tanqory-Topic, not in the body, and the body is always JSON.
- id and idempotencyKeyid is the event and stays the same on every retry of it; idempotencyKey is the fact to dedupe on.
- apiVersionDeliveries carry 2025-01 in the body.
- dataThe event's fields as listed under its topic. When an order event carries orderData, data is that object.
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Tanqory-Webhook-Id | The id of the subscription that sent it |
| X-Tanqory-Signature | t=<unix seconds>,v1=<hex>, a second v1 during a rotation |
| X-Tanqory-Event | The legacy name when the topic has one, otherwise the topic |
| X-Tanqory-Topic | The slash topic, such as orders/paid |
| User-Agent | Tanqory-Webhook/1.0 |
{ "id": "evt-example-1", "event": "order_payment", "apiVersion": "2025-01", "createdAt": "2026-10-09T09:20:00.000Z", "storeId": "store-example-01", "idempotencyKey": "order.paid:ord-example-1042", "data": { "storeId": "store-example-01", "orderId": "ord-example-1042", "orderNumber": 1042, "customerId": "cus-example-77", "timestamp": "2026-10-09T09:20:00.000Z" }}Signing
Verify the signature
Check the signature on the raw bytes before you parse the body. Three rules cover it.
- HMAC-SHA256 over <t>.<raw body>Take t from the header, join it to the exact bytes received with a dot, and sign that string.
- The secret as textUse the secret exactly as it was returned; do not hex-decode it.
- Every v1, in lowercase hexCompare against each v1 in the header, not only the last: two are sent while a rotation is open.
const crypto = require('node:crypto')// rawBody: the exact bytes received (Buffer). Do not re-serialize JSON.function verifyTanqory(rawBody, header, secret, toleranceSec = 300) { if (!header || !secret) return false let t = null const sigs = [] for (const part of header.split(',')) { const i = part.indexOf('=') if (i < 0) continue const k = part.slice(0, i).trim() const v = part.slice(i + 1).trim() if (k === 't') t = v else if (k === 'v1' && v) sigs.push(v) } if (!t || !/^\d+$/.test(t) || sigs.length === 0) return false if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false const expected = crypto .createHmac('sha256', secret) // secret string as-is, NOT hex-decoded .update(`${t}.`) .update(rawBody) .digest() // Check EVERY v1 entry: two are sent during a rotation grace window. return sigs.some((s) => { if (!/^[0-9a-f]{64}$/.test(s)) return false return crypto.timingSafeEqual(expected, Buffer.from(s, 'hex')) })}module.exports = { verifyTanqory }import hashlibimport hmacimport timedef verify_tanqory(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: """raw_body: the exact bytes received. Do not re-serialize JSON.""" if not header or not secret: return False t, sigs = None, [] for part in header.split(","): k, sep, v = part.strip().partition("=") if not sep: continue if k == "t": t = v elif k == "v1" and v: sigs.append(v) if t is None or not t.isdigit() or not sigs: return False if abs(int(time.time()) - int(t)) > tolerance: return False # secret used as UTF-8 text, NOT hex-decoded expected = hmac.new( secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256 ).hexdigest() # Check EVERY v1 entry: two are sent during a rotation grace window. return any(hmac.compare_digest(expected.encode(), s.encode()) for s in sigs)Valid: a v1 matches
Change one character of the body or the secret and the received header stops matching.
Retries
Failures and retries
A failed delivery is tried again on its own. Keep your endpoint answering, because a long run of failures turns the subscription off.
- What failsA status outside 200 to 299, a network error or no answer within 30 seconds.
- Retried with backoffFailed deliveries are retried automatically with a growing wait, and each retry is signed again with a fresh t.
- Off after 20 in a row20 failed attempts in a row turn the subscription off. A success resets the count; turning it back on does not, so fix the endpoint first.
- Every attempt loggedEach attempt is a row you can read: status, HTTP code, error, attempt number, duration and size.
GET /api/v1/stores/store-example-01/notifications/webhooks/wh-example-7f3a/attempts?page=1&limit=20{ "id": "att-example-903", "webhookId": "wh-example-7f3a", "event": "order_payment", "topic": "orders/paid", "status": "failed", "httpStatusCode": 503, "errorMessage": "Example: HTTP 503", "attemptNumber": 2, "duration": 30000, "payloadSize": 412, "attemptedAt": "2026-10-09T09:22:00.000Z"}Test and rotate
Test and rotate
Send a test delivery before real events arrive, and rotate a secret without dropping one.
- Test sendSent at once with the same headers and signature, no retries, and it works while the subscription is off. The answer tells you the status and the time it took.
- Rotate a secretThe new secret signs at once; the old one keeps signing beside it for 24 hours, so the header carries two v1, new first.
- Rotate once per windowRotating again inside the 24 hours ends the oldest secret at once. Verify every v1, not only the last, or a rotation breaks you.
{ "id": "6f1c2a9e-example-uuid", "event": "order_payment", "apiVersion": "2025-01", "createdAt": "2026-10-09T09:30:00.000Z", "storeId": "store-example-01", "idempotencyKey": "b84d07c1-example-uuid", "data": { "test": true, "message": "Test payload sent successfully", "event": "order_payment", "timestamp": "2026-10-09T09:30:00.000Z" }}X-Tanqory-Signature: t=1791561600, v1=<hmac with the new secret>, v1=<hmac with the old secret, for 24 hours>Reference
Endpoints
The REST operations under /api/v1/stores/{storeId}/notifications/webhooks. Limits are per minute for each client.
| Operation | Who | Limit |
|---|---|---|
| POST/ | Owner or admin | 10 |
| GET/ | View access | Store API default |
| GET/{webhookId} | View access | Store API default |
| PATCH/{webhookId} | Owner or admin | 30 |
| DELETE/{webhookId} | Owner or admin | 10 |
| POST/{webhookId}/test | Owner or admin | 5 |
| GET/{webhookId}/attempts | View access | Store API default |
| POST/{webhookId}/rotate-secret | Owner or admin | 5 |
| GET/signing-secret | Manage webhooks permission | Store API default |
The GraphQL Admin schema has the same: webhookSubscription and webhookSubscriptions to read, webhookSubscriptionCreate, Update, Delete, Resume and RotateSecret to write, and webhookDeliveries with webhookDeliveryReplay to read and resend deliveries.
The store-wide POST /rotate-secret is deprecated: it gives every subscription the same new secret. Rotate per subscription instead.
Wire your store to your systems
Subscribe, verify and let orders, stock and returns reach your tools the moment they happen. AI is part of every plan.
- 79 topics for stores
- HMAC-SHA256 on every delivery
- Test sends while off