Adyvance Developers
Webhooks
Adyvance tells your server when something happens in a shop, such as an order placed, paid or shipped. Each event is signed, and is sent again until your server accepts it.
Add an endpoint
In the admin, open Apps → Developer → Webhooks, choose Add endpoint, enter your https:// address and pick the events. Copy the signing secret (whsec_…); it is shown once. Send test sends a signed webhook.test event so you can check your code.
You can also add one through the API, with a key that has webhooks.manage:
curl -X POST "https://app.adyvance.com/_public/v1/orgs/org_…/webhooks" \
-H "Authorization: Bearer $ADYVANCE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/adyvance",
"events": ["order.created", "order.status_changed", "payment.state_changed"],
"description": "Accounting sync"
}'The answer includes the secret once. Use ["*"] as the events to get every event. Webhooks are part of the Business plan.
What arrives
Each event is a POST with a JSON body:
{
"id": "evt_…",
"type": "order.status_changed",
"api_version": "2026-10-01",
"org_id": "org_…",
"store_id": "str_…",
"occurred_at": "2026-11-05T09:20:03.000Z",
"resource": { "type": "order", "id": "ord_…", "seq": 4 },
"livemode": true,
"data": {
"object": { "id": "ord_…", "number": "1048", "status": "shipped", "…": "…" }
}
}| Field | Meaning |
|---|---|
id | The event's unique ID. The same event sent twice has the same ID. |
type | What happened (see events). |
resource | What it happened to, and seq, a number that goes up with each event for that same thing. |
livemode | false for events caused through a test key. |
data.object | The thing as it is after the change. |
And these headers:
POST /hooks/adyvance HTTP/1.1
Content-Type: application/json
User-Agent: Adyvance-Webhooks/1
Ady-Webhook-Id: evt_…
Ady-Webhook-Timestamp: 1793870403000
Ady-Signature: t=1793870403000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Ady-Resource: order:ord_…;seq=4The payload follows the same rules as the API: no secrets, no product costs unless the person who made the endpoint may see costs, and customers' phone numbers, emails and street addresses masked unless they may see contact details.
Check the signature
Ady-Signature is t=<time in milliseconds>,v1=<signature>. The signature is a hex HMAC-SHA256, made with your signing secret, of the time, a dot, and the raw body:t + "." + body. To check it:
- Read the raw body exactly as it arrived. Parsing and re-encoding JSON changes the bytes and breaks the check.
- Make the same HMAC with your secret and compare it to each
v1value, in constant time. - Refuse it if
tis more than 5 minutes from your clock. This stops old events being replayed.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_MS = 5 * 60 * 1000;
/**
* Checks an Adyvance webhook. `rawBody` must be the exact bytes received,
* as a string, before any JSON parsing.
*/
export function verifyAdyvanceWebhook(rawBody, signatureHeader, secret) {
let t = null;
const v1 = [];
for (const part of (signatureHeader ?? "").split(",")) {
const [k, v] = part.split("=", 2).map((s) => s?.trim());
if (k === "t") t = Number(v);
if (k === "v1" && v) v1.push(v);
}
if (!t || v1.length === 0) return false;
if (Math.abs(Date.now() - t) > TOLERANCE_MS) return false; // too old or from the future
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
// During a secret rotation there are two v1 values: accept either.
return v1.some((hex) => {
const got = Buffer.from(hex, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}import express from "express";
import { verifyAdyvanceWebhook } from "./verify.js";
const app = express();
const seen = new Set(); // use your database in production
app.post("/hooks/adyvance", express.text({ type: "application/json" }), (req, res) => {
const ok = verifyAdyvanceWebhook(
req.body,
req.get("Ady-Signature"),
process.env.ADYVANCE_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200); // answer first, then work
if (seen.has(event.id)) return; // delivered before
seen.add(event.id);
queueWork(event); // your own background job
});// Cloudflare Workers, Deno, Bun or any runtime with WebCrypto
async function verify(rawBody, header, secret) {
const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0]);
const t = Number(parts.t?.[0]?.slice(2));
const sigs = (parts.v1 ?? []).map((p) => p.slice(3));
if (!t || !sigs.length || Math.abs(Date.now() - t) > 300_000) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
return sigs.some((s) => sameHex(s, hex));
}
// Constant-time compare, so the time taken says nothing about the secret.
function sameHex(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}When you choose New signing secret in the admin, Adyvance signs with both the old and the new secret for 24 hours, so the header carries two v1 values. Accept either, and switch your server to the new secret within that time.
Answer quickly
- Answer any
2xxstatus as soon as the signature checks out, then do the work in the background. Anything else, or no answer, counts as a failure. - The same event can arrive more than once. Keep the
ids you have handled and skip repeats. - Your endpoint must use
https://on a public address. Private and local addresses are refused.
Retries
When a delivery fails, Adyvance waits and tries again: after about 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour and 2 hours, then every 3 hours. While your server is failing, other events for that endpoint wait too, so one outage does not flood you when you are back.
- An event not delivered within 24 hours stops being retried. You can still send it from the endpoint's Deliveries list in the admin with Send again.
- After 20 failures in a row, the endpoint is paused (Paused after failures) and the shop's audit log records it. Turn it back on in the admin once your server is fixed; waiting events then go out.
Order of events
Events about the same thing (the same order, product or customer) are delivered in order: the next one waits until the one before it has been delivered. Events about different things can arrive in any order. If you need to be sure, compare resource.seq, also sent in the Ady-Resource header, and ignore an event whose seq is lower than one you already handled.
Events
Subscribe to the ones you need, or to * for all of them.
Orders
order.created | An order was placed, from any channel. |
order.confirmed | An order was confirmed. |
order.status_changed | An order moved to another status. |
order.edited | An order's lines, address, note or tags changed. |
order.cancelled | An order was cancelled. |
Payments and money
payment.state_changed | An order's payment status changed. The new payment_status is in the object. |
payment_session.expired | A started online payment ran out of time. |
refund.created | A refund was made. |
invoice.issued | An invoice was issued. |
cod.remittance_posted | A courier's cash-on-delivery payout was recorded. |
settlement.matched | A payment provider's settlement was matched to orders. |
wallet.balance_changed | The shop's Adyvance Wallet balance changed. |
subscription.changed | The shop's plan changed. |
Delivery and returns
shipment.booked | A parcel was booked with a courier. |
shipment.state_changed | A parcel's courier status changed. |
return.created | A return was opened. |
return.refunded | A return was refunded. |
return.closed | A return was closed. |
Products and stock
product.created | A product was created. |
product.updated | A product changed. |
product.deleted | A product was deleted. |
inventory.level_changed | A variant's stock level changed at a location. |
inventory.movement | Stock moved in or out (a sale, adjustment, return, transfer). |
inventory.transfer_sent | A stock transfer left a location. |
inventory.transfer_received | A stock transfer arrived. |
stock_alert.triggered | A variant fell to its low-stock alert. |
Customers
customer.created | A customer was added. |
customer.updated | A customer's details changed. |
customer.anonymized | A customer's personal data was erased on request. Erase your copy too. |
consent.changed | A customer changed their marketing consent. |
Point of sale
pos.sale_completed | A sale was completed at the counter. |
pos.shift_closed | A POS shift was closed. |
Jobs
import.completed | A product or customer import finished. |
export.completed | An export file is ready. |
campaign.completed | An SMS or email campaign finished sending. |