Webhooks
Your backend learns that a design was saved or an export finished, without polling for it.
Events
| Event | Fires when |
|---|---|
design.created | A design row is first created. |
design.saved | A design is saved. Collapsed to at most one delivery per design per 30 seconds, so an autosaving editor does not flood you. |
design.deleted | A design is deleted. Archiving does not fire this. |
export.completed | An export finished and its file is downloadable. Carries the export id — re-fetch the job for a fresh signed URL. |
export.failed | An export failed permanently. |
quote.submitted | A customer submitted a quote request. |
asset.uploaded | An image finished ingesting and is usable in a design. |
session.started | An embed session was exchanged and an editor opened. |
A test delivery carries the event webhook.test. It is a real delivery through the real signing path — the point being that you can run it through the same parsing and verification code a live one goes through — but nothing ever subscribes to it, so it will not appear in your handler unless you pressed the button.
Subscribe
curl -X POST https://foldkit.theboxes.asia/api/v1/webhooks \
-H "Authorization: Bearer $FOLDKIT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/hooks/foldkit",
"events": ["design.saved", "export.completed"]
}'The response carries a signing secret. It is shown once — store it where your handler can read it. Endpoints must be https:// and name a host rather than an IP address.
No public endpoint yet? Point it at the sandbox echo and read the delivery back over HTTP. That is faster than standing up a tunnel and it gives you real bytes to test your verifier against.
The envelope
POST /hooks/foldkit
foldkit-signature: t=1757030400,v1=6a1f…
foldkit-event: design.saved
foldkit-delivery: 6f1c9c1e-…
content-type: application/json
{
"id": "6f1c9c1e-…",
"event": "design.saved",
"createdAt": "2026-09-05T01:00:00.000Z",
"tenantId": "…",
"data": { "id": "…", "name": "Gift box", "styleId": "fefco-0201" }
}Switch on event. data is shaped by the resource the event concerns and gains fields over time, so read the ones you need and ignore the rest.
Verify the signature
The scheme
foldkit-signature is t=<unix seconds>,v1=<hex>, with one v1= per active secret. Each is HMAC-SHA256(secret, `$${t}.$${rawBody}`), hex-encoded. Skip any version you do not recognise — a future v2= will arrive alongside v1=, not instead of it.
Node
import express from 'express'
import { verifyWebhookSignature } from '@foldkit/node'
// Keep the raw bytes. `express.json()` alone throws them away.
app.post(
'/hooks/foldkit',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
verifyWebhookSignature({
payload: req.body, // the Buffer
signature: req.header('foldkit-signature'),
secret: process.env.FOLDKIT_WEBHOOK_SECRET,
})
} catch (error) {
return res.status(400).send(error.message)
}
// Delivery is at-least-once and this header is stable across retries.
const deliveryId = req.header('foldkit-delivery')
if (alreadyHandled(deliveryId)) return res.sendStatus(200)
handle(JSON.parse(req.body.toString('utf8')))
res.sendStatus(200)
},
)verifyWebhookSignature throws rather than returning false, deliberately: if (!verify(…)) return 400 and verify(…) on its own look identical in a diff, and the second one is a wide-open endpoint.
PHP
<?php
function foldkit_verify(string $payload, ?string $header, string $secret, int $tolerance = 300): bool {
if ($header === null) return false;
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) continue;
if ($pair[0] === 't') $timestamp = (int) $pair[1];
if ($pair[0] === 'v1') $signatures[] = $pair[1];
}
if ($timestamp === null || !$signatures) return false;
// Replay window. Without it a captured delivery is valid for ever.
if (abs(time() - $timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $candidate) {
// Every v1=, not just the first: a rotation sends two.
if (hash_equals($expected, $candidate)) return true;
}
return false;
}
// The raw body — never json_decode then re-encode.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_FOLDKIT_SIGNATURE'] ?? null;
if (!foldkit_verify($payload, $header, getenv('FOLDKIT_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('Bad signature');
}
$delivery = json_decode($payload, true);
// $_SERVER['HTTP_FOLDKIT_DELIVERY'] is your idempotency key.Python
import hashlib
import hmac
import time
def foldkit_verify(payload: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
# Replay window. Without it a captured delivery is valid for ever.
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + payload,
hashlib.sha256,
).hexdigest()
# Every v1=, not just the first: a rotation sends two.
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
# Flask
@app.post("/hooks/foldkit")
def foldkit_webhook():
payload = request.get_data() # raw bytes, not request.json
if not foldkit_verify(payload, request.headers.get("foldkit-signature"), SECRET):
return "Bad signature", 400
delivery_id = request.headers.get("foldkit-delivery")
if already_handled(delivery_id):
return "", 200
handle(json.loads(payload))
return "", 200Idempotency
Delivery is at-least-once. A network timeout after your handler committed but before your 200 reached us produces a retry, and your handler will see the same event twice.
foldkit-deliveryis stable across every retry of one delivery — it is the delivery’s id, not the attempt’s. Store it and skip what you have already processed.
Retries
Anything outside 2xx is a failure and is retried up to 12 times on this schedule:
after failure 1 … 1s … 5s … 30s … 2min … 10min … 30min … 1h … 2h … 4h … 8h … 16h … 24h
- Answer
200fast. Queue the work; do not do it inline. A slow handler becomes a timeout, which becomes a retry, which becomes a duplicate. 410 Gonedisables the endpoint immediately. It is the web’s “stop sending me this”, and we honour it rather than retrying for a day.- Every attempt, its status and its response body are in your console’s delivery log, and any delivery can be replayed by hand.
Rotating the secret
Rotation opens an overlap: for its duration every delivery is signed with both the new secret and the previous one, so foldkit-signature carries two v1= values and either verifies.
Deploy the new secret during the overlap, then rotate again to discard the old one. All three verifiers above already accept any matching v1=; a hand-rolled one that reads only the first will start rejecting half your deliveries the moment you rotate.