Skip to content

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:

  1. 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.
  2. Compute HMAC-SHA-256(your signing secret, raw body bytes), hex-encoded.
  3. Compare that to the X-Karpa-Signature header value using a constant-time string comparison (most languages have one, e.g. Node’s crypto.timingSafeEqual, Python’s hmac.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.

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": "[email protected]"
},
"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 LinkedIn
epik Pinterest
rdtCid Reddit
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": "[email protected]", "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": "[email protected]", "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": "[email protected]" },
"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": "[email protected]", "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": "[email protected]" },
"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": "[email protected]" },
"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": "[email protected]" },
"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": "[email protected]" },
"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).