Integration Docs

WebhooksReal-time events

Geteken sends an HTTP POST to your endpoint every time a document status changes — immediately, with a signed HMAC signature so you know it came from us.

Quick start

  1. Go to Account → Webhooks in the dashboard and add your endpoint URL.
  2. Choose the events you want to subscribe to (or subscribe to all).
  3. Copy your webhook secret and store it safely — it is shown once.
  4. Validate every incoming request by verifying the X-Geteken-Signature header.

Events

Every webhook delivery carries a top-level event field. These are all the events you can subscribe to:

Internal event nameWire event valueDescription
envelope.sentdocument_state_changedDocument has been sent to all recipients.
envelope.vieweddocument_state_changedAt least one recipient has opened the document.
envelope.completeddocument_completedAll recipients have signed — the gold seal is awarded.
envelope.declineddocument_state_changedA recipient declined to sign.
envelope.voideddocument_state_changedDocument was voided by the sender.
envelope.expireddocument_state_changedThe signing window expired without completion.
recipient.signedrecipient_completedOne recipient placed their signature (before overall completion).
recipient.vieweddocument_state_changedA 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:

jsonPOST body
{
  "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:

document.draft
document.sent
document.viewed
document.completed
document.rejected
document.voided

Recipient status

Each item in recipients[] carries a status field:

pendingsentviewedsigneddeclinedexpired

HMAC 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.

httpRequest headers (example)
POST /webhooks/geteken HTTP/1.1
Host: api.your-app.com
Content-Type: application/json
X-Geteken-Signature: sha256=3a9b2c1d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789
X-Signing-Event: envelope.completed

Verifying the signature

Edge / Cloudflare Workers (Web Crypto)

typescriptedge-verification.ts
/**
 * 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)

javascriptnode-verification.js
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.

jsonPOST /public/v1/documents — request body
{
  "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:

jsonWebhook payload (excerpt)
{
  "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 URLThe HTTPS destination — plain HTTP is not accepted.
  • Subscribed eventsChoose specific internal event names or subscribe to * (all events).
  • Webhook secretAuto-generated at creation. You can rotate it at any time — new requests immediately use the new secret.
  • Active / inactiveDisable an endpoint without deleting it. No deliveries are sent while inactive.

Ready to integrate?

Add your first webhook endpoint in under a minute.