DevelopersWebhooks

Webhooks

Receive SKUFlow's events at your own URL, signed and retried.

A webhook sends an event to your server the moment something happens in SKUFlow: a customer is created, a quote is approved, an invoice is paid. Use webhooks instead of asking the API for changes over and over.

Add an endpoint

Owners and admins manage webhooks.

Open Webhooks

Go to Settings → Developers, click Webhooks, then Add endpoint.

The Add a webhook endpoint form

Screenshot coming soon

Enter your URL

Under Endpoint URL, enter your server's address. It must be https; private and internal addresses are refused. Add a Description so you know what it's for.

Choose the events

Under Events to send, pick All events (including event types added later) or Selected events, chosen by area or one by one. See the Event catalog.

Save the signing secret

Click Add endpoint. Copy the signing secret (it starts with whsec_) and click I've saved it. It's shown only once; you need it to check that events really come from SKUFlow.

Send a test

On the endpoint's page, click Send test event. SKUFlow sends a webhook.test event at once and shows your server's answer.

A business can have up to 20 endpoints. You can also manage them through the API (Webhooks API); adding or changing an endpoint with an API key waits for your approval.

What SKUFlow sends

Each event is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSKUFlow-Webhooks/1.0
SKUFlow-Event-IdThe event's id. The same event always has the same id.
SKUFlow-Event-TypeThe event type, such as invoice.paid.
SKUFlow-Signaturet=<unix seconds>,v1=<signature>: see Verify the signature.

The event body

{
	"id": "…",
	"type": "invoice.paid",
	"occurredAt": "2026-11-03T15:04:05.000Z",
	"organizationId": "…",
	"data": {
		"recordId": "…",
		"identifier": "INV-40"
	}
}
FieldMeaning
idThe event's id.
typeThe event type. The part before the dot is the record type (invoice.paid → an invoice).
occurredAtWhen it happened, ISO 8601 in UTC.
organizationIdYour business's id.
dataThe event's details: always recordId, often identifier, and the fields in the Event catalog.

Event data holds ids, statuses, amounts (in cents) and dates only, never names, addresses, notes or costs. To get the full record, call the API with data.recordId.

Answer quickly

Answer with any 2xx status within 10 seconds. Anything else, a redirect, or no answer in time counts as a failure. Do the real work after you answer (put the event on a queue), so slow work doesn't cause retries.

Retries

A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts in all. After the last one it is marked Failed.

  • If 5 deliveries in a row fail every attempt, SKUFlow turns the endpoint off and notifies the person who added it in the app. Fix your server, click Turn on, then Resend the failed deliveries.
  • On the endpoint's page, Deliveries shows every attempt with your server's status code, answer and timing. Resend sends any delivery again now.
  • Events are never lost while an endpoint is off or failing: they stay in the Event log in Settings → Developers, where you can see each event's data and where it was delivered.

Handle duplicates and order

The same event can arrive more than once (a retry after a slow answer, or a resend). Use the SKUFlow-Event-Id header to skip events you've already handled. Events can also arrive out of order; use occurredAt, or read the record from the API, when order matters.

Verify the signature

Every delivery is signed with your endpoint's secret, so you can tell it came from SKUFlow and wasn't changed. The SKUFlow-Signature header looks like this:

SKUFlow-Signature: t=1793804645,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

To check it:

  1. Read t (the time it was signed, in Unix seconds) and the v1 value from the header.
  2. Compute an HMAC-SHA256 of the string "<t>.<raw body>" with your signing secret, as hex.
  3. Compare it with v1 using a constant-time comparison.
  4. Refuse it if t is more than 5 minutes from your clock (it could be a replay).

Use the raw body exactly as received. Parsing the JSON and turning it back into text changes the bytes and breaks the signature.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const secret = process.env.SKUFLOW_WEBHOOK_SECRET; // whsec_…

function verifySkuflowSignature(header, rawBody, toleranceSeconds = 300) {
	if (!header) return false;
	let timestamp = NaN;
	const signatures = [];
	for (const part of header.split(",")) {
		const [key, value] = part.trim().split("=", 2);
		if (key === "t") timestamp = Number(value);
		else if (key === "v1" && value) signatures.push(value);
	}
	if (!Number.isFinite(timestamp) || signatures.length === 0) return false;
	if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

	const expected = Buffer.from(
		createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"),
		"hex",
	);
	return signatures.some((signature) => {
		const given = Buffer.from(signature, "hex");
		return given.length === expected.length && timingSafeEqual(given, expected);
	});
}

const app = express();

// express.raw keeps the body as bytes, exactly as SKUFlow sent it.
app.post("/skuflow/webhooks", express.raw({ type: "application/json" }), (req, res) => {
	const rawBody = req.body.toString("utf8");

	if (!verifySkuflowSignature(req.get("SKUFlow-Signature"), rawBody)) {
		return res.status(400).send("Bad signature");
	}

	const event = JSON.parse(rawBody);
	res.sendStatus(204); // answer first, then do the work
	handleEvent(event);
});

The header can carry more than one v1 value; accept the delivery if any of them matches.

Rotate the secret

On the endpoint's page, click Regenerate secret. The old secret stops working at once and every delivery from then on is signed with the new one, so update your server straight after.

Turn off or delete an endpoint

  • Turn off pauses deliveries; they wait and go out when you turn it back on.
  • Delete endpoint removes it and its delivery log. Events stay in the event log.

On this page