CRM Webhook: API Reference (v1)
Karpa sends a signed message to your endpoint whenever a lead opts in, a Case is submitted or resolved, a payment succeeds, or a prescription is sent or shipped. This reference covers the envelope format, signature verification, deduplication, and the event catalog.
The envelope
Every message Karpa sends (including the test ping) is wrapped in the same outer shape, called the envelope:
{ "contractVersion": 1, "eventType": "test", "eventId": "b6df...-uuid", "deliveryId": "9a31...-uuid", "occurredAt": "2026-08-01T00:00:00.000Z", "data": { "ping": true }}| Field | Meaning |
|---|---|
contractVersion |
Version of the envelope format. Additive changes do not bump it. |
eventType |
What kind of thing happened. One of the values in the event catalog. |
eventId |
A unique ID for the underlying business event (e.g. one specific Case submission). |
deliveryId |
A unique ID for this specific delivery attempt. Karpa may resend the same event if the first attempt fails. Always use this ID to avoid processing the same message twice (“deduplication,” see below). |
occurredAt |
When the real-world event happened (not necessarily when you receive it). |
data |
The actual event-specific payload; its shape depends on eventType. |
New optional keys may be added inside data without a contractVersion bump. Ignore keys
you don’t recognise rather than rejecting the message.
Verifying the signature (HMAC-SHA-256)
Every request includes three headers:
| Header | Meaning |
|---|---|
X-Karpa-Delivery-Id |
Same value as deliveryId in the body, convenient to read without parsing JSON. |
X-Karpa-Signature |
A hex-encoded HMAC-SHA-256 signature proving the message came from Karpa. |
X-Karpa-Timestamp |
When Karpa signed this specific delivery attempt. |
To verify a request:
- Read the raw request body exactly as received. Do not re-format, re-indent, or re-parse-and-re-stringify it. Any change to whitespace or key order will break verification, because the signature was computed over the exact bytes Karpa sent.
- Compute
HMAC-SHA-256(your signing secret, raw body bytes), hex-encoded. - Compare that to the
X-Karpa-Signatureheader value using a constant-time string comparison (most languages have one, e.g. Node’scrypto.timingSafeEqual, Python’shmac.compare_digest). Never use a plain===/==check for this, as it can leak timing information an attacker could exploit.
Worked example (Node.js):
const crypto = require('node:crypto');
function isValidKarpaSignature(rawBody, signatureHeader, signingSecret) { const expected = crypto.createHmac('sha256', signingSecret).update(rawBody).digest('hex'); const a = Buffer.from(signatureHeader); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b);}Deduplication (why deliveryId matters)
Karpa’s delivery is at-least-once, not exactly-once, meaning if your endpoint is
slow or unreachable, Karpa will retry, and it’s possible (though rare) for the same
delivery to arrive more than once. Your receiver should keep a short-lived record of
deliveryId values it has already processed, and skip any repeat.
Event catalog
Each event is delivered as an envelope whose data matches one of the shapes below.
Every event includes the contact object (name and email of the customer, plus an
optional phone when the customer supplied a number at the intake SMS/email consent)
and marketingConsent: true (events only fire for customers who opted in). phone is
omitted when the customer did not provide one, so treat it as optional.
The examples below show each event’s own fields. On top of those, an event can also carry
the optional objects documented in this catalog: attribution on all seven events,
consent when it was captured, and contactConsents on the two lead events only. Ignoring
them is safe; a receiver that ignores unknown keys needs no change when one is added.
The order-lifecycle events (case_submitted, case_resolved, and payment_succeeded) and
the rx fulfillment events (rx_sent and rx_shipped) all carry two stable order
identifiers: caseId (the Case UUID) and orderNumber (the human order reference, e.g.
47 for “Case #47”). Use these to join every event for one order across its lifecycle and
to reconcile payments and fulfillment to cases.
attribution and consent (added 2026-09-10)
Both objects are additive and optional: a receiver that ignores them needs no change, and
messages sent before this date simply lack them. Each carries its own schemaVersion, so the
nested shapes can evolve without a version bump.
The objects are properties of the event-specific data object in the envelope. For example,
the path to the attribution object is data.attribution:
{ "contractVersion": 1, "eventType": "lead_created", "eventId": "b6df...-uuid", "deliveryId": "9a31...-uuid", "occurredAt": "2026-09-10T21:12:03.000Z", "data": { "contact": { "name": "Jane Smith", "email": "jane@example.com" }, "marketingConsent": true, "attribution": { "schemaVersion": 1, "anonymousVisitorId": "22222222-2222-4222-8222-222222222222", "acquisition": { "classification": "attributed", "source": "facebook", "medium": "cpc", "campaign": "q1-glp1", "content": "creative_a", "term": null, "gclid": null, "fbclid": "F1", "landingPath": "/weight-loss", "referrerOrigin": "https://l.instagram.com", "capturedAt": "2026-06-01T10:00:00.000Z" }, "latest": { "...same shape..." } }, "consent": { "schemaVersion": 1, "capturedAt": "2026-09-10T21:12:03.000Z", "version": "d7fc3521744b900c238cead79845288db93bf5359bc14a7511d77ff5d2ed93c9" } }}Click IDs. Ad platforms add these automatically when auto-tagging is on. Karpa passes
them through so you can upload the conversion back to the platform that produced the click;
Karpa does not use them itself. null means the visitor did not arrive with that platform’s
ID.
| Field | Platform |
|---|---|
gclid, gbraid, wbraid |
Google Ads |
dclid |
Google Display & Video 360 |
fbclid |
Meta |
msclkid |
Microsoft Ads |
ttclid |
TikTok |
twclid |
X |
liFatId |
|
epik |
|
rdtCid |
|
sccid |
Snapchat |
Reading it correctly.
| Situation | What you receive |
|---|---|
| First non-direct touch known | acquisition.classification = "attributed" with the captured fields |
| Visitor landed, but nothing non-direct | acquisition.classification = "direct" |
| No record at all (blocked storage, expired retention, pre-change) | attribution.acquisition = null, meaning unknown, not “direct” |
latest |
lead_created only; absent on the other events |
| Consent not captured | the consent key is omitted, never a false or empty consent |
acquisition.gpcSignaled (optional, only when true) tells you the visitor’s browser sent a
do-not-sell-or-share signal on that visit. Karpa passes the data through rather than
suppressing it. It is your customer’s data, arriving in your own system, so the choice of
what to do with it is yours. If you upload contacts to an advertising platform for audience
matching, that upload is the point at which the signal is most likely to bind, and this field
is how you can exclude those contacts.
acquisition is frozen at the visitor’s first non-direct touch. A later branded search appears
only as latest. anonymousVisitorId is an anonymous, browser-scoped UUID, not a customer or
patient identifier, and the session identifier is deliberately not sent.
consent.version is the SHA-256 of the disclosure copy the customer agreed to at the moment
they agreed to it. Treat it as an opaque version tag; it changes when the wording changes.
Note that marketingConsent continues to describe Karpa’s own account-gate disclosure. It is
not a statement about your own consent instrument.
contactConsents (added 2026-09-20)
A permission to call your customers. The customer gives it at the account gate, Karpa writes the wording of the instrument, and you decide in Settings > Patient Experience whether it is shown. Until you turn it on, this key never appears on your events.
Two things produce an absent key, and the date in this heading bounds one of them: anything delivered before 2026-09-20 could not carry it, so absence in older history says nothing about what the customer answered. Absence since then means either you have not enabled an instrument or the customer was never asked.
It rides the two lead events (lead_created and lead_disqualified) and nowhere else.
Consent is a property of the contact, and the lead event is where the contact is created.
Here is a whole lead_created body, so you can see where it sits. attribution is left out
to keep this short; it is documented above and unchanged.
{ "contractVersion": 1, "eventType": "lead_created", "eventId": "b6df...-uuid", "deliveryId": "9a31...-uuid", "occurredAt": "2026-09-20T21:12:03.000Z", "data": { "contact": { "name": "Jane Smith", "email": "jane@example.com", "phone": "5551234567" }, "programSlug": "weight-loss", "marketingConsent": true, "consent": { "schemaVersion": 1, "capturedAt": "2026-09-20T21:12:03.000Z", "version": "d7fc3521744b900c238cead79845288db93bf5359bc14a7511d77ff5d2ed93c9" }, "contactConsents": { "schemaVersion": 1, "grants": [ { "key": "voiceAi", "granted": true, "capturedAt": "2026-09-20T21:12:03.000Z", "version": "3cbeaa044697f7c572d82ba1d509c92da9a9bee518d5e70baa2331f61701e539" } ] } }}The same field when the customer was asked and declined. This is still a delivery worth acting on: it tells you not to call.
"contactConsents": { "schemaVersion": 1, "grants": [ { "key": "voiceAi", "granted": false, "capturedAt": "2026-09-20T21:12:03.000Z", "version": "3cbeaa044697f7c572d82ba1d509c92da9a9bee518d5e70baa2331f61701e539" } ]}When you have the instrument switched off, or the customer was never asked, the
contactConsents key is absent from data entirely. Karpa does not send an empty grants
array, though the schema accepts one.
Reading it correctly. One entry per instrument the customer answered.
| Situation | What you receive |
|---|---|
| Asked and agreed | an entry with granted: true |
| Asked and declined | an entry with granted: false |
| Not asked (instrument off, or never rendered) | the contactConsents key is absent |
No entry means never asked. It does not mean “declined”, and it is not permission to call.
Treat a missing entry and granted: false the same way for suppression, but never read either
as consent.
key identifies the instrument. voiceAi is the call-consent instrument, which covers
calls including those using an artificial, prerecorded, or AI-generated voice. Keys are stable
and permanent: map them to your own suppression logic rather than matching on wording. A key you
have never seen is one Karpa added later, and it is safe to ignore.
version is the SHA-256 of the exact wording the customer saw. Treat it as an opaque
version tag and store it with the grant. It is your evidence of what was agreed to, and it
changes when the wording changes.
Revocation is not represented on the wire. Karpa sends an answer once, at capture. If a
customer later revokes, no event corrects the earlier one, and their grant stays granted: true
in your system. Honoring a revocation is yours, as the party doing the calling, because the
customer has no switch in Karpa to flip.
Note that contactConsents is separate from marketingConsent (Karpa’s own account-gate
disclosure) and from consent (its provenance). marketingConsent: true is not a statement
about your instrument, and your instrument is not marketing consent.
lead_created
Fires when a customer opts in to marketing contact during the intake flow.
programSlug is the intake program the lead entered (e.g. weight-loss,
peptide-therapy). Use it to segment leads by program from the funnel top.
{ "eventType": "lead_created", "contact": { "name": "Jane Smith", "email": "jane@example.com", "phone": "5551234567" }, "programSlug": "weight-loss", "marketingConsent": true}Can also carry attribution, consent, and contactConsents. The last one is the customer’s
answer about being called, and it appears on this event and lead_disqualified only.
lead_disqualified
Fires when an applicant is disqualified by an intake eligibility/safety block after
having opted in. Corrective: a lead_created may already have been delivered, and the
destination is expected to mark the contact as not eligible rather than treat them as a
fresh lead. Carries no reason (a disqualifying reason could expose a medical
condition), so destinations should not attempt to reconstruct why the lead was
disqualified from this event.
{ "eventType": "lead_disqualified", "contact": { "name": "Jane Smith", "email": "jane@example.com" }, "marketingConsent": true}Carries the same optional objects as lead_created, contactConsents included. The grants are
the customer’s recorded answer, so a destination that suppresses on them does not need the
correction’s arrival order to interpret them.
case_submitted
Fires when a customer submits a Case. Treatments is an array to represent multi-treatment/bundle Cases as a single event.
treatments[].priceCents is the treatment’s list price (before any discount).
The intake-applied partner discount and the expected total the customer pays are
carried separately on the event:
| Field | Meaning |
|---|---|
discountCents |
Partner discount applied at intake (0 when no code was used). |
totalCents |
Expected total the customer pays: sum(treatments[].priceCents) − discountCents, floored at zero. The final settled charge may differ by at most 50¢ if the settlement-side discount floor applies. |
{ "eventType": "case_submitted", "caseId": "11111111-1111-4111-8111-111111111111", "orderNumber": 47, "contact": { "name": "Jane Smith", "email": "jane@example.com", "phone": "5551234567" }, "treatments": [{ "name": "Semaglutide", "priceCents": 19900 }], "discountCents": 0, "totalCents": 19900, "marketingConsent": true}case_resolved
Fires only on terminal Case outcomes, never intermediate clinical-review states.
{ "eventType": "case_resolved", "caseId": "11111111-1111-4111-8111-111111111111", "orderNumber": 47, "contact": { "name": "Jane Smith", "email": "jane@example.com" }, "outcome": "approved", "marketingConsent": true}outcome is approved or rejected.
payment_succeeded
Fires on a successful payment. Amount, currency, and status only.
{ "eventType": "payment_succeeded", "caseId": "11111111-1111-4111-8111-111111111111", "orderNumber": 47, "contact": { "name": "Jane Smith", "email": "jane@example.com" }, "amountCents": 19900, "currency": "USD", "status": "succeeded", "marketingConsent": true}rx_sent
Fires when the pharmacy reports a prescription has been sent. A heads-up only; it does not carry carrier, tracking, quantity, or dose details.
{ "eventType": "rx_sent", "caseId": "11111111-1111-4111-8111-111111111111", "orderNumber": 47, "contact": { "name": "Jane Smith", "email": "jane@example.com" }, "treatmentName": "Semaglutide", "marketingConsent": true}rx_shipped
Fires when the pharmacy reports a prescription has shipped. Same shape and scope as
rx_sent.
{ "eventType": "rx_shipped", "caseId": "11111111-1111-4111-8111-111111111111", "orderNumber": 47, "contact": { "name": "Jane Smith", "email": "jane@example.com" }, "treatmentName": "Semaglutide", "marketingConsent": true}The test event
The test delivery uses the exact same envelope and signing as a real delivery, but with
"eventType": "test" and "data": { "ping": true }. Use it to confirm your endpoint
responds with a 2xx status code and that your signature verification passes.
What’s excluded from this contract
This integration is deliberately scoped to marketing/retention use cases. It will never include: fulfillment/shipping/tracking information, or anything from the clinical chart (intake answers, clinical notes, vitals, flags, documents, messages, provider identity, diagnoses, medication dosing/directions, clinical rationale, or pharmacy details).