Back to Arky

Webhooks

Receive real-time event notifications

Webhooks allow you to receive real-time notifications when events occur in your Arky store.

Configuration

Enable Webhooks

Create a webhook for the store:

await sdk.store.webhook.create({
	store_id: "store_abc123",
	key: "orders",
	url: "https://yourapp.com/api/webhooks/arky",
	secret: "whsec_your_secret_key",
	headers: {},
	events: [
		{ event: "order.created" },
		{ event: "order.payment_received" },
		{ event: "order.shipment_delivered" },
		{ event: "order_booking.reminder" },
	],
	enabled: true,
});

Webhook URLs, signing secrets, and custom header values are write-only. List and update responses contain redacted placeholders; omit these fields from an update to keep their stored values. Never send the •••••••• placeholder back to the API.

Available Events

Orders

| Event | Description | | --------------------------------- | --------------------------------- | | order.created | New order placed | | order.updated | Order updated | | order.confirmed | Order confirmed | | order.payment_received | Payment successful | | order.payment_failed | Payment failed | | order.refunded | Order refunded | | order.cancelled | Order cancelled | | order_booking.reminder | Confirmed booking reminder | | order_product.created | Product line created | | order_product.updated | Product line updated | | order_product.confirmed | Product line confirmed | | order_product.cancelled | Product line cancelled | | order_product.fulfilled | Product line fulfilled | | order_booking.created | Booking created | | order_booking.updated | Booking updated | | order_booking.confirmed | Booking confirmed | | order_booking.completed | Booking completed | | order_booking.no_show | Booking marked no-show | | order_booking.cancelled | Booking cancelled | | order.shipment_created | Shipment created | | order.shipment_in_transit | Shipment in transit | | order.shipment_out_for_delivery | Out for delivery | | order.shipment_delivered | Shipment delivered | | order.shipment_failed | Shipment failed | | order.shipment_returned | Shipment returned | | order.shipment_status_changed | Shipment status changed |

Webhook Payload

All webhooks include:

{
	"id": "evt_abc123",
	"type": "order.payment_received",
	"timestamp": 1704067200,
	"store_id": "store_xyz789",
	"store": {},
	"contact": {},
	"data": {
		"action": "order_payment_received",
		"data": {}
	}
}

Example Payloads

order.payment_received

{
	"id": "evt_abc123",
	"type": "order.payment_received",
	"timestamp": 1704067200,
	"store_id": "store_xyz789",
	"data": {
		"action": "order_payment_received",
		"data": {
			"id": "ord_123",
			"status": "confirmed",
			"payment": {
				"id": "payment_123",
				"type": "stripe",
				"status": "paid",
				"amount": 5999,
				"currency": "USD"
			}
		}
	}
}

order.created

{
	"id": "evt_def456",
	"type": "order.created",
	"timestamp": 1704067200,
	"store_id": "store_xyz789",
	"data": {
		"action": "order_created",
		"data": {
			"id": "ord_123",
			"number": "O-0001",
			"status": "pending",
			"contact_id": "contact_789",
			"payment": { "status": "requires_action" }
		}
	}
}

Order-level events carry the lean Order root. Product and booking lifecycle changes are delivered as their own order_product.* and order_booking.* events; do not expect a generic order.items array or embedded payment in an order event. Fetch only the explicit order-scoped children the handler needs.

Handling Webhooks

Node.js / Express

import express from "express";
import crypto from "crypto";

const app = express();

// Parse raw body for signature verification
app.post(
	"/api/webhooks/arky",
	express.raw({ type: "application/json" }),
	(req, res) => {
		const signature = req.headers["x-webhook-signature"];
		const timestamp = req.headers["x-webhook-timestamp"];

		// Verify signature
		if (!verifySignature(req.body, signature, timestamp)) {
			return res.status(401).send("Invalid signature");
		}

		const event = JSON.parse(req.body.toString());

		// Handle event
		switch (event.event) {
			case "order.payment_received":
				handleOrderPaid(event.data);
				break;
			case "order.created":
				handleOrderCreated(event.data);
				break;
			case "order.shipment_delivered":
				handleOrderDelivered(event.data);
				break;
		}

		res.status(200).send("OK");
	},
);

function verifySignature(
	payload: Buffer,
	signature: string,
	timestamp: string,
): boolean {
	const webhookSecret = process.env.ARKY_WEBHOOK_SECRET!;
	const signedPayload = `${timestamp}.${payload.toString()}`;

	const expectedSignature = crypto
		.createHmac("sha256", webhookSecret)
		.update(signedPayload)
		.digest("hex");

	return crypto.timingSafeEqual(
		Buffer.from(signature),
		Buffer.from(expectedSignature),
	);
}

Next.js API Route

// app/api/webhooks/arky/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function POST(req: NextRequest) {
	const body = await req.text();
	const signature = req.headers.get("x-webhook-signature")!;
	const timestamp = req.headers.get("x-webhook-timestamp")!;

	if (!verifySignature(body, signature, timestamp)) {
		return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
	}

	const event = JSON.parse(body);

	try {
		await handleWebhook(event);
		return NextResponse.json({ received: true });
	} catch (err) {
		console.error("Webhook error:", err);
		return NextResponse.json({ error: "Handler failed" }, { status: 500 });
	}
}

async function handleWebhook(event: WebhookEvent) {
	switch (event.type) {
		case "order.payment_received":
			await sendOrderConfirmation(event.data.data);
			break;

		case "order.created":
			await handleOrderCreated(event.data.data);
			break;

		case "order.shipment_delivered":
			await markOrderDelivered(event.data.data.order);
			break;
	}
}

Event Handlers

Order Fulfillment

import { formatMinor } from "arky-sdk/utils";

async function handleOrderPaid(data: OrderPaidData) {
	const order = data;
	const fullOrder = await sdk.eshop.order.get({ id: order.id });

	// 1. Send confirmation email
	await sendEmail({
		to: await contactEmail(order.contact_id),
		template: "order-confirmation",
		data: {
			orderNumber: order.id,
			products: fullOrder.products,
			bookings: fullOrder.bookings,
			total: formatMinor(order.money.total, order.money.currency),
		},
	});

	// 2. Update inventory
	for (const item of fullOrder.products) {
		await db.product.update({
			where: { id: item.product_id },
			data: { inventory: { decrement: item.quantity } },
		});
	}

	// 3. Create shipping label
	if (order.shipping_address) {
		await createShippingLabel(order);
	}

	// 4. Notify team
	await slack.send({
		channel: "#orders",
		text: `New order #${order.id} - ${formatMinor(order.money.total, order.money.currency)}`,
	});
}

Appointment Reminders

async function handleServiceScheduled(data: ServiceData) {
	const booking = data;

	// Schedule reminder 24h before
	const reminderTime = booking.from - 86400;

	await scheduleJob({
		type: "order-service-reminder",
		runAt: reminderTime,
		data: { orderId: booking.order_id, bookingId: booking.id },
	});

	// Add to provider's calendar
	await addToCalendar({
		providerId: booking.booking_provider_id,
		title: `Order booking ${booking.id}`,
		start: booking.from,
		end: booking.to,
	});
}

Testing Webhooks

Test Endpoint

import {
	clearDurableRequest,
	getOrCreateDurableRequest,
	withDurableRequestLock,
} from "arky-sdk/utils";

const request = { webhook_id: "wh_abc123" };
const storageKey = "arky:webhook-test:store_abc123:wh_abc123";

const delivery = await withDurableRequestLock(
	storageKey,
	"webhook test",
	async () => {
		const durable = getOrCreateDurableRequest(
			storageKey,
			request,
			"webhook test",
		);
		const response = await sdk.store.webhook.test({
			delivery_id: durable.id,
			...request,
		});
		if (response.delivery_id !== durable.id) {
			throw new Error("Webhook response did not match the requested delivery");
		}
		if (
			["succeeded", "rejected", "failed", "unknown"].includes(response.status)
		) {
			clearDurableRequest(durable, "webhook test");
		}
		return response;
	},
);

console.log(delivery.status);

Reuse the same delivery ID and webhook ID to read the delivery’s current state. An unknown outcome is terminal and is not automatically sent again.

Local Development

Use a tunnel service for local testing:

# Using ngrok
ngrok http 3000

# Update webhook URL temporarily
# https://abc123.ngrok.io/api/webhooks/arky

Best Practices

Tip

Delivery policy: Arky makes at most one external attempt for each durable webhook delivery. It records an ambiguous outcome as unknown instead of automatically sending again. Store the event id before applying your own side effects so your receiver remains defensive.

  1. Verify signatures - Always validate webhook signatures
  2. Respond quickly - Return 200 within 5 seconds, process async
  3. Store event IDs - Make receiver-side processing safe against duplicate input
  4. Log safely - Record event IDs and delivery metadata, not signing secrets or full payloads
  5. Use queues - Queue heavy processing for reliability
async function handleWebhook(event: WebhookEvent) {
	// Check if already processed
	const processed = await db.webhookEvent.findUnique({
		where: { id: event.id },
	});

	if (processed) {
		console.log("Duplicate webhook, skipping:", event.id);
		return;
	}

	// Mark as processing
	await db.webhookEvent.create({
		data: { id: event.id, event: event.event, status: "PROCESSING" },
	});

	// Queue for async processing
	await queue.add("webhook", event);
}