Foldkit docs

Webhooks

Your backend learns that a design was saved or an export finished, without polling for it.

Events

EventFires when
design.createdA design row is first created.
design.savedA design is saved. Collapsed to at most one delivery per design per 30 seconds, so an autosaving editor does not flood you.
design.deletedA design is deleted. Archiving does not fire this.
export.completedAn export finished and its file is downloadable. Carries the export id — re-fetch the job for a fresh signed URL.
export.failedAn export failed permanently.
quote.submittedA customer submitted a quote request.
asset.uploadedAn image finished ingesting and is usable in a design.
session.startedAn 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

npm install @foldkit/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

No dependencies
<?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

No dependencies beyond the standard library
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 "", 200

Idempotency

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 200 fast. Queue the work; do not do it inline. A slow handler becomes a timeout, which becomes a retry, which becomes a duplicate.
  • 410 Gone disables 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.