Ikrames Docs
Developers

Webhooks

Get confirmed enquiries pushed to Make, Zapier, n8n — or your own endpoint — signed and versioned.

When somebody confirms an enquiry on your brand page, Ikrames can POST it to a URL you choose. That is the whole feature: structured handover, not a CRM. What you build on the other side is yours.

Set it up

  1. Go to Anfragen ▸ Übergabe and add your endpoint URL. It must be https.
  2. Copy the signing secret. It is shown exactly once. If you lose it, you can rotate it — but rotating invalidates any automation still using the old one.
  3. Press Test senden. You get a sample payload with .invalid data, so your automation can learn the shape without a real enquiry.

The events

There are four. Every active endpoint of a brand receives all of them.

ereignisWhen it fires
lead.confirmedThe person clicked the confirmation link in their email — not when the form was submitted. An unconfirmed enquiry is not yet an enquiry.
lead.file_uploadedA file was attached to a confirmed enquiry. Always after lead.confirmed for that enquiry — a file cannot arrive before the confirmation.
lead.deletedThe brand removed the enquiry from its inbox. What you do with your copy is your decision.
lead.erasure_requestedThe person asked for erasure. This one is not a notification. See below.

lead.erasure_requested obliges you

When you receive lead.erasure_requested, delete that record — and anything you derived from it. The payload carries "pflicht": "loeschen" so you can branch on a field rather than on a name. We have deleted our copy already; if yours stays, the person's erasure request is only half honoured, and the half that remains is in your system, not ours.

lead.deleted is the other case and deliberately carries a different name: a brand tidying its inbox tells you nothing about that person's wishes.

The two deletion events carry no personal data

No address, no answers, no filenames — only anfrage.id, the same identifier you already received with lead.confirmed. Sending an address in order to announce its deletion would be the processing that is supposed to end. Match on the id.

The payload

{
  "nutzlast_fassung": "2026-09-23",
  "ereignis": "lead.confirmed",
  "event_id": "8f14e45f-…",
  "erzeugt_at": "2026-09-23T10:00:00.000Z",
  "marke": { "name": "Studio Nord" },
  "formular": {
    "titel": "Illustrations-Anfrage",
    "slug": "illustration",
    "fassung": 2,
    "bedingungen": "Zwei Revisionsrunden sind enthalten.",
    "fassung_hash": "sha256:1f3a…"
  },
  "gruppe": null,
  "anfrage": {
    "id": "…",
    "email": "person@example.com",
    "eingegangen_at": "2026-09-23T09:58:00.000Z",
    "bestaetigt_at": "2026-09-23T10:00:00.000Z",
    "einwilligung_fassung": "2026-09-23",
    "antworten": [
      { "schluessel": "projektziel", "label": "Worum geht es?", "wert": "Ein Buchcover, Aquarell." }
    ],
    "anhaenge": 2,
    "posteingang_url": "https://ikrames.com/anfragen"
  }
}

Two things worth knowing about it:

  • Answers carry labels, not just keys. label is the question as the person actually read it — taken from a copy stored with the enquiry, so it stays correct even if you edit the form afterwards.
  • There are no file URLs. Attachments belong to the person who uploaded them; anhaenge tells you how many there are, and posteingang_url takes you to them behind your login. A link that survives in a Notion row would not be a link we could take back.

The other three payloads

They share the same envelope — nutzlast_fassung, ereignis, event_id, erzeugt_at, marke, formular — and differ only in the body.

lead.file_uploaded:

{
  "ereignis": "lead.file_uploaded",
  "anfrage": { "id": "…", "anhaenge": 2, "posteingang_url": "https://ikrames.com/anfragen" },
  "datei": { "typ": "application/pdf", "bytes": 248512 }
}

anhaenge is the count after this upload, so use the field rather than counting events — a retry would make you count twice. There is no filename: the name of someone else's file is often itself a disclosure.

lead.deleted and lead.erasure_requested:

{
  "ereignis": "lead.erasure_requested",
  "anfrage": { "id": "…" },
  "pflicht": "loeschen"
}

pflicht appears only on lead.erasure_requested.

Testing

The Test senden button in Übergabe sends any of the four shapes to your endpoint. A test always carries "ereignis": "test" and names the shape in test_fuer, so you can reject test runs on one stable field — otherwise a rehearsal of lead.deleted would be indistinguishable from the real thing.

Versions

nutzlast_fassung versions this schema. formular.fassung_hash versions the form the person answered. They change for different reasons — pin your automation to the first, and use the second to spot when a form was edited.

Idempotency

event_id is unique per delivery. We retry on failure, so the same event_id can arrive more than once — store it and skip duplicates.

Verifying the signature

Every request carries:

Ikrames-Signature: t=1758600000,v1=<64 hex chars>
Ikrames-Event-Id: <uuid>

The signed material is `${t}.${rawBody}`, and the key is derived from your secret:

key = HMAC_SHA256(key = <your secret>, msg = "ikrames:uebergabe-webhook:v1")
v1  = HMAC_SHA256(key = key,          msg = `${t}.${rawBody}`)   // hex

Node example:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header.trim());
  if (!m) return false;

  const t = Number(m[1]);
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;

  const key = createHmac("sha256", secret).update("ikrames:uebergabe-webhook:v1").digest();
  const expected = createHmac("sha256", key).update(`${t}.${rawBody}`).digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(m[2], "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

A worked example you can paste into a test — secret whsec_beispiel, t = 1758600000, body {"ereignis":"test"}:

v1 = da447fda5709e61fd58df14be025f88cad14a66c4d1ef6b3a03eb131f6c8aa85

Three details that are easy to miss:

  • Sign the raw body, not a re-serialised object. JSON.parse followed by JSON.stringify changes key order and whitespace, and the signature no longer matches.
  • Check the timestamp. Without it, anybody who once captured a valid request can replay it forever. Five minutes is a reasonable window.
  • Compare in constant time. === on hex strings leaks how much of the signature was right.

Retries and pausing

A delivery that fails is retried with backoff. If a target fails 10 times in a row, we pause it and say so on the Übergabe page, with the reason. All the retries for one enquiry count as one failure — a single outage on your side will not pause anything.

A paused target sends nothing until you press Wieder aktivieren.

What we keep

The delivery log keeps status, attempt count and response code. The payload itself is deleted after seven days — it is a second copy of the enquiry, and the enquiry is already in your inbox.

On this page