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
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.
- Verify signatures - Always validate webhook signatures
- Respond quickly - Return 200 within 5 seconds, process async
- Store event IDs - Make receiver-side processing safe against duplicate input
- Log safely - Record event IDs and delivery metadata, not signing secrets or full payloads
- 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);
}