Listening to Webhooks
Blueprint notifies you when clinical artifacts are ready. This is the reliable way to get
documentation into your application — unlike frontend callbacks, webhooks still arrive after the
clinician closes their browser.
Setting up
Register a callback URL for your partner organization:
curl -X PATCH https://api.blueprint.ai/v2/partners/{partnerId} \
-H 'Content-Type: application/json' \
-H 'Access-Token: YOUR_ACCESS_TOKEN' \
-H 'X-API-Key: YOUR_API_KEY' \
-d '{"callbackUrl":"https://your-app.example.com/blueprint/webhooks"}'Read the current value with GET /v2/partners.
There is one callback URL per partner organization per environment, and all event types are
delivered to it. There is no per-event subscription or filtering — branch on eventType in your
handler.
What you receive
A POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Blueprint-Signature | HMAC-SHA256 hex digest of the request body, keyed with your clientSecret |
Those are the only two headers Blueprint sets. In particular no
X-API-Keyheader is sent — do
not gate your endpoint on one, or you will reject every event.
The body:
{
"eventType": "progress_note_generated",
"timeStamp": "2026-07-29T18:04:11.522Z",
"payload": {
"progressNoteId": "44444444-4444-4444-4444-444444444444",
"sessionId": "55555555-5555-5555-5555-555555555555",
"sessionExternalId": "appt-99031",
"clientId": "...",
"clinicianId": "...",
"clinicId": "...",
"organizationId": "...",
"progressNoteUrl": "https://api.blueprint.ai/v2/sessions/555.../progress-note"
}
}| Field | Notes |
|---|---|
eventType | One of the seven events below |
timeStamp | ISO 8601. Note the capital S. |
payload | Shape varies by event type |
Payloads carry identifiers and a URL rather than the artifact itself. Fetch the URL with your normal
Access-Token and X-API-Key headers to get the content.
Event types
There are seven. A handler that only knows about progress notes will receive events it does not
recognize, so branch explicitly and ignore the rest.
| Event | Fires when | Resource URL in payload |
|---|---|---|
progress_note_generated | A progress note has been created | progressNoteUrl |
progress_note_regenerated | A progress note has been regenerated | progressNoteUrl |
progress_note_finalized | A note was finalized and locked against edits | progressNoteUrl |
transcript_ready | The session transcript is available | transcriptUrl |
session_transcript_error | Transcription failed | none — see error |
mdm_elements_identified | Medical decision making elements are available | mdmUrl |
assessment_completed | A client completed an assessment | assessmentScoreUrl per score |
Full payload field lists are in Webhook Events Reference.
Different events carry different URL fields. Reading
payload.progressNoteUrlon a
transcript_readyevent gives youundefined. Switch oneventTypebefore reaching for a URL.
Verifying the signature
Verify every request before trusting it. Anyone who can reach your endpoint can post to it.
The signature is an HMAC-SHA256 hex digest of the exact request body, keyed with your partner
clientSecret — not your API key.
Verify against the raw body bytes, not a re-serialized object. Blueprint signs the exact string it
transmits. If you parse the JSON and re-stringify it to verify, you are hashing a different string
that only happens to match today; it will break on non-ASCII content or if a proxy or body parser
normalizes anything.
const crypto = require('crypto')
const express = require('express')
const app = express()
// Capture the raw body. This is the important part.
app.post('/blueprint/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-blueprint-signature']
const expected = crypto
.createHmac('sha256', process.env.BLUEPRINT_CLIENT_SECRET)
.update(req.body) // req.body is a Buffer of the raw bytes
.digest('hex')
// Constant-time comparison
const a = Buffer.from(String(signature ?? ''), 'utf8')
const b = Buffer.from(expected, 'utf8')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(req.body.toString('utf8'))
handleEvent(event).catch(err => console.error(err))
// Acknowledge immediately; do the work asynchronously.
res.status(200).send('ok')
}
)Rotating your
clientSecretinvalidates webhook verification. The signing key and your OAuth
client secret are the same value, and there is no separate signing secret or overlap window. Plan
secret rotation as a coordinated change.
Responding
Return a 2xx as soon as you have stored the event. Do the real work asynchronously. The per-request
timeout is 10 seconds; slower than that counts as a failure.
Retries
| Your response | What happens |
|---|---|
| 2xx | Delivered. Done. |
| 5xx, 408, 429 | Retried. Redelivered after a delay, for a bounded number of attempts. |
| Timeout or connection error | Retried, same as above. |
| Any other non-2xx — 400, 401, 403, 404, … | Not retried. The event is dropped permanently and you are not notified. |
That last row is the one to design around. A deploy that briefly returns 401, or a routing change that
returns 404, will silently lose every event that arrives during the window. Two defenses:
- Never return a 4xx for a transient problem. If you cannot process an event right now — database
unavailable, dependency down — return 503, not 400. A 4xx tells Blueprint the request was
malformed and not worth retrying. - Return 2xx even for events you do not recognize. An unknown
eventTypeis not an error. Log it
and acknowledge.
async function handleEvent(event) {
switch (event.eventType) {
case 'progress_note_generated':
case 'progress_note_regenerated':
return importNote(event.payload)
case 'progress_note_finalized':
return markFinalized(event.payload)
case 'transcript_ready':
return importTranscript(event.payload)
case 'session_transcript_error':
return recordFailure(event.payload)
case 'mdm_elements_identified':
return importMdm(event.payload)
case 'assessment_completed':
return importScores(event.payload)
default:
console.warn('Unrecognized Blueprint event', event.eventType)
return // acknowledge anyway
}
}Handling duplicates
Delivery is at-least-once, so you can receive the same event more than once — from a retry after a
response your side did deliver, or from redelivery. There is no delivery ID in the payload to
deduplicate on, so use the natural key:
| Event group | Dedupe on |
|---|---|
| Progress note events | eventType + payload.progressNoteId |
transcript_ready, session_transcript_error, mdm_elements_identified | eventType + payload.sessionId |
assessment_completed | eventType + each payload.assessmentScores[].id |
Make your handler idempotent on that key — upsert rather than insert.
Ordering is also not guaranteed. In particular progress_note_regenerated can arrive out of order
relative to progress_note_generated. If you need the current state of a note, fetch it rather than
assuming the last event you saw reflects it.
Local development
Blueprint has to be able to reach your callback URL, so localhost will not work. Use a tunnel
(ngrok, cloudflared) and point your sandbox callbackUrl at it. Remember to change it back.
Next
- Webhook Events Reference — every event's payload fields
- Errors — fetching artifacts that are not ready yet
- Pagination & Limits — idempotency in the rest of the API
Updated about 1 month ago
