Back to Arky

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 ends unacknowledged at 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, and order_product_item.* (created, updated, confirmed, cancelled) and order_booking_item.* (created, updated, confirmed, attended, no_show, cancelled, reminder);
  • refunds: refund.created when a refund is requested in Arky or first seen from Stripe, refund.succeeded when its money has gone out, and refund.failed when it ends without returning money, including money that comes back after succeeding. Each fires once per refund with { order, payment_id, refund_id }, and refund.succeeded also carries notify_customer;
  • fulfillment: fulfillment.created, fulfillment.ready, fulfillment.dispatched, fulfillment.delivered, fulfillment.collected, fulfillment.tracking_updated, fulfillment.cancelled, and fulfillment_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 and store.updated;
  • customers: customer.verified (when an email is first proven; a new anonymous customer posts nothing), customer.updated, customer.merged (naming both customers) and customer.erased;
  • customer groups (customer_group.created, updated, deleted) and their members: customer_group_member.activated, paused, blocked, resumed, cancelled, renewed, payment_failed, switched, renewal_skipped and payment_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");
});
Note

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.