Webhooks
Pick store events and verify the signed posts Arky sends
A webhook sends the store events it picks to an HTTPS address. It owns its URL, its own list of
events, extra headers, its signing secret and its status. Each post of one event to one webhook is an
outgoing webhook (sdk.notification.outgoingWebhook), retried until the receiver accepts it or
the tries run out.
Create A Webhook
const webhook = await sdk.store.webhook.create({
store_id: storeId,
id: crypto.randomUUID(),
url: "https://example.com/api/webhooks/arky",
secret: process.env.ARKY_WEBHOOK_SECRET!,
headers: {},
events: [
{ type: "order.confirmed" },
{ type: "order.payment_received" },
{ type: "fulfillment.dispatched" },
{ type: "order_booking_item.reminder" },
{ type: "customer.verified" },
],
status: { type: "active" },
});
status: { type: "disabled" }pauses posting; a post that is still waiting endsunacknowledgedat its next try.- The URL, the secret and the header values are stored encrypted and come back masked. Keep the secret in your backend; never ship it to a browser.
- Private and internal addresses are refused.
Manage Webhooks
const webhooks = await sdk.store.webhook.find({ store_id: storeId });
await sdk.store.webhook.update({
store_id: storeId,
id: webhook.id,
expected_updated_at: webhook.updated_at,
status: { type: "disabled" },
});
await sdk.store.webhook.delete({ store_id: storeId, id: webhook.id, expected_updated_at: webhook.updated_at });
Fields left out of an update stay as they are. Updates and deletes carry the updated_at they read.
Events
A webhook lists each event at most once; an empty list sends nothing. There are 113 events, and Arky posts every one of them:
- content:
collection.created,collection.updated,collection.deleted,entry.created,entry.updated,entry.deleted; - orders:
order.created,order.updated,order.confirmed,order.payment_received,order.payment_failed,order.cancelled, andorder_product_item.*(created, updated, confirmed, cancelled) andorder_booking_item.*(created, updated, confirmed, attended, no_show, cancelled, reminder); - refunds:
refund.createdwhen a refund is requested in Arky or first seen from Stripe,refund.succeededwhen its money has gone out, andrefund.failedwhen it ends without returning money, including money that comes back after succeeding. Each fires once per refund with{ order, payment_id, refund_id }, andrefund.succeededalso carriesnotify_customer; - fulfillment:
fulfillment.created,fulfillment.ready,fulfillment.dispatched,fulfillment.delivered,fulfillment.collected,fulfillment.tracking_updated,fulfillment.cancelled, andfulfillment_job.*(created, opened, held, released, assigned, moved, completed, cancelled); - carts (created, updated, abandoned, converted), products, product variants, prices,
inventory_level.updated(whenever a level’s quantities change), promotions and promotion codes, booking services and resources, media, categories andstore.updated; - customers:
customer.verified(when an email is first proven; a new anonymous customer posts nothing),customer.updated,customer.merged(naming both customers) andcustomer.erased; - customer groups (
customer_group.created,updated,deleted) and their members:customer_group_member.activated,paused,blocked,resumed,cancelled,renewed,payment_failed,switched,renewal_skippedandpayment_method_changed; - companies, company locations and company memberships (created, updated, deleted);
- returns (
return.requested,opened,approved,destination_decided,declined,cancelled,received,disposed,missing,tracking_updated,closed) and rentals (rental.created,rental.updated); form_submission.created.
Collection events filter by collection key and entry events by collection and entry key, each with a typed filter; form submission events name their forms:
await sdk.store.webhook.create({
store_id: storeId,
id: crypto.randomUUID(),
url: "https://example.com/api/content-events",
secret: process.env.ARKY_CONTENT_WEBHOOK_SECRET!,
headers: {},
events: [
{ type: "entry.updated", collections: { type: "only", collection_ids: [pagesCollectionId] }, entries: { type: "all" } },
{ type: "form_submission.created", forms: { form_ids: [leadFormId] } },
],
status: { type: "active" },
});
sdk.platform.findWebhookEvents() lists every event with the filters it takes.
Site Rebuilds
To rebuild a static site when content changes, create a webhook for the entry.* events (and
product or media events when your pages use them) pointed at your host’s deploy hook. Arky has no
separate build-hook record.
The Post
Every post body has this shape:
import type { EpochMilliseconds } from "arky-sdk";
interface WebhookPost<T> {
id: string;
type: string;
timestamp: EpochMilliseconds;
store_id: string;
store: { id: string; name: string; timezone: string; languages: string[] };
customer: { id: string; store_id: string; email: string | null } | null;
data: T;
}
id is the event id (evt_...), the same on every try: use it to ignore duplicates. data is the
event’s payload, such as the order, the customer or the form submission; customer is the customer
the event is about, when there is one. Payloads have no version and grow by adding fields. Read
anything else you need through the API.
Verify Signatures
Arky signs every try fresh and sends:
X-Webhook-Signature: v1=<hex HMAC>X-Webhook-Timestamp: <epoch milliseconds>X-Webhook-Event: <event type>X-Webhook-Id: <the outgoing webhook's id>
The signature is HMAC-SHA256 over <timestamp>.<raw request body>, with the webhook’s secret.
Verify the raw bytes before parsing JSON:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyArkyWebhook(rawBody: Buffer, timestamp: string, signatureHeader: string, secret: string): boolean {
const supplied = signatureHeader.startsWith("v1=") ? signatureHeader.slice(3) : "";
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
if (supplied.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
}
Refuse timestamps outside your replay window, compared with Date.now() in milliseconds. Arky keeps
no old secret when you change it, so accept both secrets while you switch.
Express Handler
import express from "express";
const app = express();
app.post("/api/webhooks/arky", express.raw({ type: "application/json" }), async (request, response) => {
const signature = String(request.headers["x-webhook-signature"] || "");
const timestamp = String(request.headers["x-webhook-timestamp"] || "");
if (!verifyArkyWebhook(request.body, timestamp, signature, process.env.ARKY_WEBHOOK_SECRET!)) {
return response.status(401).send("Invalid signature");
}
const event = JSON.parse(request.body.toString("utf8"));
if (await hasProcessed(event.id)) return response.status(200).send("Already processed");
await recordReceived(event);
return response.status(200).send("OK");
});Answer 2xx only once the event is safely recorded in your system, and hand slow work to a queue.
Retries
A 2xx answer ends the post acknowledged. A try fails on any other answer, a timeout or a failed
connection. The next try waits 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then 24
hours; when the eighth fails, about 45 hours after the first, the post ends unacknowledged. Each
try reads the webhook’s current URL, headers and secret, so a corrected address or a new secret
takes effect at the next try. Nothing stops a waiting post: disabling or deleting the webhook ends
it unacknowledged at its next try.
const posts = await sdk.notification.outgoingWebhook.find({ store_id: storeId, webhook_id: webhook.id, limit: 20 });
A post is waiting, sending, retrying (with the next try and the reason), acknowledged or
unacknowledged. An unacknowledged post doesn’t prove the receiver applied nothing. Finished posts
are kept for thirty days. Reading posts needs the store’s admin permission, like managing
webhooks.
Test A Webhook
const test = await sdk.store.webhook.test({
store_id: storeId,
id: crypto.randomUUID(),
webhook_id: webhook.id,
});
const latest = await sdk.notification.outgoingWebhook.get({ store_id: storeId, id: test.id });
console.log(latest.status.type);
The test posts a webhook.test event. Its id is the outgoing webhook’s id, picked by your code:
sending it again returns the same post instead of sending another.
Rules For Receivers
- Verify the signature against the raw body and enforce a timestamp window.
- Make processing idempotent by event id.
- Keep handlers fast and queue slow work.
- Log event and post ids, never signing secrets or full customer payloads.