Quick start
- Go to
Account → Webhooksin the dashboard and add your endpoint URL. - Choose the
eventsyou want to subscribe to (or subscribe to all). - Copy your
webhook secretand store it safely — it is shown once. - Validate every incoming request by verifying the
X-Geteken-Signatureheader.
Events
Every webhook delivery carries a top-level event field. These are all the events you can subscribe to:
| Internal event name | Wire event value | Description |
|---|---|---|
| envelope.sent | document_state_changed | Document has been sent to all recipients. |
| envelope.viewed | document_state_changed | At least one recipient has opened the document. |
| envelope.completed | document_completed | All recipients have signed — the gold seal is awarded. |
| envelope.declined | document_state_changed | A recipient declined to sign. |
| envelope.voided | document_state_changed | Document was voided by the sender. |
| envelope.expired | document_state_changed | The signing window expired without completion. |
| recipient.signed | recipient_completed | One recipient placed their signature (before overall completion). |
| recipient.viewed | document_state_changed | A recipient opened their link for the first time. |
The wire event field follows PandaDoc convention so existing Bubble workflows work without changes. The internal event name is also sent as internal_event in every payload.
Payload shape
Geteken POSTs the following JSON structure to your endpoint:
{
"event": "document_state_changed", // or "document_completed" / "recipient_completed"
"internal_event": "envelope.sent", // precise internal name for routing
"occurred_at": "2025-06-08T10:23:45.123Z",
"data": {
"id": "env_01hx...", // Geteken envelope UUID
"name": "Service Agreement — Acme Corp",
"status": "document.sent", // PandaDoc-compatible status string
"date_created": "2025-06-08T09:00:00.000Z",
"date_modified": "2025-06-08T10:23:44.000Z",
"metadata": {
"external_ref": "CRM-4821", // your correlation ID (see Metadata section)
"tags": ["sales", "q2"]
},
"recipients": [
{
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Smith",
"role": "signer",
"signing_order": 1,
"status": "signed",
"signed_date": "2025-06-08T10:23:44.000Z",
"first_viewed": "2025-06-08T09:15:00.000Z",
"declined_date": null
}
]
}
}Status values
The data.status field uses PandaDoc-convention strings:
Recipient status
Each item in recipients[] carries a status field:
pendingsentviewedsigneddeclinedexpiredHMAC signature
Every request carries an X-Geteken-Signature header. The value is sha256=hex — an HMAC-SHA256 of the raw request body (UTF-8 encoded) keyed with the webhook secret you stored in the dashboard.
Always verify the signature
Reject any request whose signature does not match. Use constant-time comparison (see examples below) to prevent timing attacks.
POST /webhooks/geteken HTTP/1.1
Host: api.your-app.com
Content-Type: application/json
X-Geteken-Signature: sha256=3a9b2c1d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789
X-Signing-Event: envelope.completedVerifying the signature
Edge / Cloudflare Workers (Web Crypto)
/**
* Verify a Geteken webhook request using the Web Crypto API.
* Works on Cloudflare Workers, Deno, and Node 18+ (no 'node:crypto').
*/
export async function verifyGetekenSignature(
rawBody: string,
signatureHeader: string, // value of X-Geteken-Signature
secret: string, // your webhook secret from the dashboard
): Promise<boolean> {
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) return false;
const hexSig = signatureHeader.slice(prefix.length).toLowerCase().trim();
// Parse hex signature into bytes
if (hexSig.length % 2 !== 0) return false;
const sigBytes = new Uint8Array(hexSig.length / 2);
for (let i = 0; i < sigBytes.length; i++) {
sigBytes[i] = parseInt(hexSig.slice(i * 2, i * 2 + 2), 16);
}
// Import the secret key
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
// Compute expected HMAC
const expected = new Uint8Array(
await crypto.subtle.sign('HMAC', key, enc.encode(rawBody)),
);
// Constant-time comparison — prevents timing attacks
if (expected.length !== sigBytes.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ sigBytes[i];
return diff === 0;
}
// Example: Cloudflare Workers / Next.js Edge Route
export async function POST(req: Request) {
const rawBody = await req.text();
const sig = req.headers.get('x-geteken-signature') ?? '';
const secret = process.env.GETEKEN_WEBHOOK_SECRET ?? '';
if (!(await verifyGetekenSignature(rawBody, sig, secret))) {
return new Response('Forbidden', { status: 403 });
}
const event = JSON.parse(rawBody);
// handle event.event / event.internal_event …
return new Response('OK', { status: 200 });
}Node.js (crypto module)
const crypto = require('node:crypto');
function verifyGetekenSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader?.startsWith('sha256=')) return false;
const receivedHex = signatureHeader.slice(7);
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
// timingSafeEqual needs Buffer of equal length
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(receivedHex, 'hex'),
);
} catch {
return false; // length mismatch
}
}
// Express middleware example
app.post('/webhooks/geteken', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-geteken-signature'] ?? '';
if (!verifyGetekenSignature(req.body.toString('utf8'), sig, process.env.GETEKEN_WEBHOOK_SECRET)) {
return res.status(403).send('Forbidden');
}
const event = JSON.parse(req.body);
console.log(event.event, event.data.id);
res.sendStatus(200);
});Retry policy
Initial attempt
Immediately on event
Maximum attempts
5
Backoff schedule
1 min → 5 min → 30 min → 2 h → 6 h
Success codes
HTTP 200–299
Any HTTP response outside the 200–299 range (including connection errors and timeouts) is treated as a failure. Geteken automatically retries up to five times. Deliveries are logged in the dashboard Webhooks view.
Be idempotent
Your endpoint may receive the same delivery more than once due to retries. Use data.id (the envelope UUID) together with internal_event as an idempotency key.
Metadata correlation
When you create a document via the REST API, you can set a metadata.external_ref field. This value appears unchanged in every webhook payload related to the envelope — the fastest way to link a Geteken envelope back to a record in your system without storing our ID.
{
"name": "Service Agreement — Acme Corp",
"template_uuid": "tpl_01hx...",
"recipients": [
{ "email": "jane@example.com", "role": "signer" }
],
"metadata": {
"external_ref": "CRM-4821", // your own ID; echoed on every webhook
"deal_stage": "closing",
"tags": ["sales", "q2"]
}
}When the envelope status changes, your endpoint receives:
{
"event": "document_completed",
"data": {
"id": "env_01hx...",
"metadata": {
"external_ref": "CRM-4821", // still here, unchanged
"deal_stage": "closing",
"tags": ["sales", "q2"]
}
}
}Configuring endpoints
Visit Account → Webhooks in the dashboard to add, edit, or disable endpoints. Each endpoint has:
- Endpoint URL — The HTTPS destination — plain HTTP is not accepted.
- Subscribed events — Choose specific internal event names or subscribe to * (all events).
- Webhook secret — Auto-generated at creation. You can rotate it at any time — new requests immediately use the new secret.
- Active / inactive — Disable an endpoint without deleting it. No deliveries are sent while inactive.
Ready to integrate?
Add your first webhook endpoint in under a minute.