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:

HeaderValue
Content-Typeapplication/json
X-Blueprint-SignatureHMAC-SHA256 hex digest of the request body, keyed with your clientSecret

Those are the only two headers Blueprint sets. In particular no X-API-Key header 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"
  }
}
FieldNotes
eventTypeOne of the seven events below
timeStampISO 8601. Note the capital S.
payloadShape 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.

EventFires whenResource URL in payload
progress_note_generatedA progress note has been createdprogressNoteUrl
progress_note_regeneratedA progress note has been regeneratedprogressNoteUrl
progress_note_finalizedA note was finalized and locked against editsprogressNoteUrl
transcript_readyThe session transcript is availabletranscriptUrl
session_transcript_errorTranscription failednone — see error
mdm_elements_identifiedMedical decision making elements are availablemdmUrl
assessment_completedA client completed an assessmentassessmentScoreUrl per score

Full payload field lists are in Webhook Events Reference.

Different events carry different URL fields. Reading payload.progressNoteUrl on a
transcript_ready event gives you undefined. Switch on eventType before 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 clientSecret invalidates 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 responseWhat happens
2xxDelivered. Done.
5xx, 408, 429Retried. Redelivered after a delay, for a bounded number of attempts.
Timeout or connection errorRetried, 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 eventType is 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 groupDedupe on
Progress note eventseventType + payload.progressNoteId
transcript_ready, session_transcript_error, mdm_elements_identifiedeventType + payload.sessionId
assessment_completedeventType + 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


Did this page help you?