Skip to main content

Webhooks

Instead of keeping a process alive to wait for a workflow, give GISL a callback URL and let it POST lifecycle events to your server. Every delivery is signed with HMAC-SHA256 using a secret that belongs to that one workflow, so you can prove a POST came from GISL and was not altered.

  • The signature arrives in the X-GIS-Signature header as sha256=<64 hex>.
  • The secret is returned once, when the workflow is created, as webhookSecret. Store it keyed by workflow id before you discard the response.
  • No callback URL means no secret: it is only generated when a callback is set.

Subscribe a workflow​

submit() uploads, creates the workflow with your URL as its callback, and returns a handle without waiting.

import { gisl, OptimizeFor } from '@giveitsmaller/sdk';

const client = await gisl.create({ apiKey: 'REPLACE_ME_API_KEY' });

const handle = await client
.file('./photo.jpg')
.compress(OptimizeFor.Balanced)
.submit('https://your-app.example/gisl/callback');

// Persist both: you need the secret to verify every delivery.
await saveSecret(handle.workflowId, handle.webhookSecret);

With the low-level createWorkflow(), set callback_url on the payload and read webhookSecret from the response. callback_events narrows which events are POSTed; events not listed are not sent.

Verify a delivery​

Pass the stored secret, the raw X-GIS-Signature header value, and the raw request body. The verifier returns true on success and throws GislError on any failure (missing sha256= prefix, wrong hex length, or a mismatch); it never returns false. The final comparison is constant-time.

Use the raw body

The signature covers the exact bytes GISL sent. If a framework parses the JSON and you re-serialise it, key order and whitespace change and verification fails. Capture the raw body before any JSON body parser runs.

verifyWebhook uses node:crypto, so it is exported from the Node entry point only, not the browser build. The body may be a string or a Buffer.

import express from 'express';
import { verifyWebhook, GislError } from '@giveitsmaller/sdk';

const app = express();

app.post(
'/gisl/callback',
// express.json() would consume the stream; take the raw bytes instead.
// Payloads embed the full workflow status, so raise the 100kb default.
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
const signature = req.header('X-GIS-Signature');
if (!signature) return res.status(401).send('missing signature');

// Unverified input: parse defensively, only to find which secret to use.
let workflowId: unknown;
try {
workflowId = JSON.parse(req.body.toString('utf8'))?.workflow?.workflow_id;
} catch {
return res.status(400).send('invalid JSON body');
}
if (typeof workflowId !== 'string') return res.status(400).send('no workflow id');

const secret = await loadSecret(workflowId);
if (!secret) return res.status(404).send('unknown workflow');

try {
verifyWebhook(secret, signature, req.body);
} catch (err) {
if (err instanceof GislError) return res.status(401).send('invalid signature');
throw err;
}

// Verified. Answer 2xx only once the delivery is handled, so a failure here
// is not acknowledged as a success.
try {
await handleDelivery(JSON.parse(req.body.toString('utf8')));
} catch {
return res.sendStatus(500);
}
res.sendStatus(204);
},
);

Other frameworks, same rule:

  • Next.js App Router: await req.text() in the route handler. Keep the route on the Node.js runtime (do not set runtime = 'edge'), or the import fails.
  • Fastify: read request.rawBody (opt-in) or pass the buffer through a contentTypeParser.
  • AWS Lambda / API Gateway: event.body, base64-decoded when event.isBase64Encoded is set.

Replay protection​

Verification proves authenticity only; it does not stop the same delivery being handled twice. The payload carries its own delivery metadata on the wire in snake_case: delivery_id, event_type, timestamp (ISO 8601) and workflow (the full workflow status). The TypeScript SDK exports a WebhookPayload type describing the camelCase shape you get if you run the body through the contracts deserializer; match the casing to how you parsed it.

To get at-most-once handling, claim each delivery_id atomically before doing any side effect: an insert into a unique column, or a Redis SET NX with a TTL. If the claim fails, another handler already has it; skip. A plain "have I seen this?" read followed by a write lets two concurrent redeliveries both pass. If handling fails before any side effect, release the claim so a redelivery can run; if a side effect may already have happened, keep the claim and reconcile, or make that step idempotent, because a redelivery would repeat it.

Do not reject a delivery for being old: timestamp is when the event happened, and a redelivery keeps it, so a legitimate late retry would be thrown away. Use delivery_id for deduplication.

What can go wrong​

SymptomLikely cause
Invalid signature formatThe header value is missing its sha256= prefix, or you stripped it. Pass the header value unchanged.
Webhook signature verification failed on every deliveryYou are verifying a re-serialised body. Capture the raw bytes before any JSON parser.
Verification fails with the raw bodyWrong secret: it belongs to another workflow, or the workflow was re-created and you kept the old one. Store secrets per workflow id.
No webhookSecret on the created workflowNo callback URL was set.
A delivery never arrivesNo callback URL on the workflow, callback_events excludes that event, or your endpoint answered with a non-2xx status.
The same event handled twiceNo dedupe layer. Track delivery_id.

See also​