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-Signatureheader assha256=<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.
- TypeScript
- PHP
- Python
- Rust
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.
use Gisl\Sdk\Gisl;
use Gisl\Sdk\Generated\SdkSpec\Enums\OptimizeFor;
$client = Gisl::create(apiKey: 'sk_...');
$handle = $client->file('./photo.jpg')
->compress(OptimizeFor::Balanced)
->submit(webhook: 'https://your-app.example/gisl/callback');
// Persist both: you need the secret to verify every delivery.
saveSecret($handle->workflowId, $handle->webhookSecret);
With the low-level createWorkflow(), set the callback URL on the
WorkflowCreatePayload and read the secret back with
$created->getWebhookSecret().
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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.
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.
- TypeScript
- PHP
- Python
- Rust
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 setruntime = 'edge'), or the import fails. - Fastify: read
request.rawBody(opt-in) or pass the buffer through acontentTypeParser. - AWS Lambda / API Gateway:
event.body, base64-decoded whenevent.isBase64Encodedis set.
use Gisl\Sdk\Webhook;
use Gisl\Sdk\Errors\GislError;
$rawBody = (string) $request->getBody(); // PSR-7: the raw bytes
try {
Webhook::verify(
secret: $storedWebhookSecret,
signature: $request->getHeaderLine('X-GIS-Signature'),
body: $rawBody,
);
} catch (GislError $e) {
return new Response(401); // missing, malformed, or mismatched
}
$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR); // snake_case keys
In Symfony and Laravel, read $request->getContent(), not the parsed request
parameters.
To work with a typed object instead of an array, deserialize the verified body
into the WebhookPayload model:
use Gisl\Generated\OpenApi\Model\WebhookPayload;
use Gisl\Generated\OpenApi\ObjectSerializer;
$decoded = json_decode($rawBody, false, flags: JSON_THROW_ON_ERROR);
$payload = ObjectSerializer::deserialize($decoded, WebhookPayload::class);
$payload->getWorkflow()->getStatus();
$payload->getDeliveryId();
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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
| Symptom | Likely cause |
|---|---|
Invalid signature format | The header value is missing its sha256= prefix, or you stripped it. Pass the header value unchanged. |
Webhook signature verification failed on every delivery | You are verifying a re-serialised body. Capture the raw bytes before any JSON parser. |
| Verification fails with the raw body | Wrong 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 workflow | No callback URL was set. |
| A delivery never arrives | No callback URL on the workflow, callback_events excludes that event, or your endpoint answered with a non-2xx status. |
| The same event handled twice | No dedupe layer. Track delivery_id. |
See also
- Workflows: the other ways to get results back.
- Progress streaming (SSE): live events without a public endpoint.
- Errors