Webhooks
Receive real-time notifications when events occur in your Flexpa integration.
Flexpa sends HTTPS POST requests to your endpoint instead of requiring you to poll for changes.
#Available events
- sync_completed: Patient data synchronization is complete and ready to query via the FHIR API
- sync_failed: Patient data synchronization failed
- refresh_expired: Flexpa can no longer refresh a patient authorization; the user must re-consent
- endpoint_status_changed: A Flexpa-registered endpoint's connectivity status changed
#Setup
Configure your webhook endpoints directly in Portal. You can add, update, or remove webhooks and manage their secrets from the Portal dashboard.
Webhooks are configured separately for test and live modes. A webhook will only receive events matching its configured mode.
Webhook payload
{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event": "sync_completed",
"version": 1,
"timestamp": 1704067200000,
"data": {
"mode": "live",
"external_id": "user_123",
"patient_authorization_id": "7c3e9f2a-4b8d-4e1f-9a3c-5d7e8f9a1b2c",
"consent_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}
Request headers
Content-Type: application/json
User-Agent: Flexpa-Webhook/1.0
X-Flexpa-Signature: t=1704067200,v1=5f7d8a...
#Verifying signatures
Every webhook request includes an X-Flexpa-Signature header that contains a timestamp and signature.
You should verify this signature to ensure the request came from Flexpa.
The signature format is: t={timestamp},v1={signature}
#Verification steps
- Extract the timestamp and signature from the header
- Construct the signed payload as:
{timestamp}.{request_body}
- Compute an HMAC-SHA256 hash using your webhook secret
- Compare the computed signature with the received signature
- Verify the timestamp is recent (within 5 minutes) to prevent replay attacks
Always use a constant-time comparison function when comparing signatures to prevent timing attacks.
Signature verification
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
// Parse signature header
const [tPart, v1Part] = signature.split(',');
const timestamp = parseInt(tPart.split('=')[1]);
const receivedSignature = v1Part.split('=')[1];
// Verify timestamp is recent (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return false;
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Use constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
#Handling events
#sync_completed
Triggered when Flexpa has finished synchronizing a patient's data from their health plan.
This event fires after each data synchronization, including the initial sync when a patient connects and subsequent refreshes for MULTIPLE usage authorizations.
Payload fields
- event_id
Unique identifier for this event (UUID v4).
Store this to implement idempotent processing, as Flexpa may send the same event multiple times due to retries.
- event
Always "sync_completed".
- version
Payload schema version (currently 1).
- timestamp
Unix timestamp in milliseconds when the event occurred.
- data.mode
The mode for this authorization: "test" or "live". This will always match the mode of the webhook that received the event.
- data.external_id
Your application's user identifier for this patient, if one was passed through the Consent SDK. This field is optional and is omitted from the payload when no external_id was supplied at session creation.
- data.consent_id
The consent ID associated with this patient authorization.
A consent represents a single user session in Flexpa Consent and may result in multiple patient authorizations.
Handling sync_completed
import express from 'express';
// Use raw body capture so signature verification operates on the exact
// bytes Flexpa signed. JSON.stringify(req.body) is not byte-stable.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-flexpa-signature'];
const payload = req.body.toString('utf8');
// Verify signature
if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event_id, event, data } = JSON.parse(payload);
// Check for duplicate events
if (await isDuplicateEvent(event_id)) {
return res.status(200).send('Already processed');
}
if (event === 'sync_completed') {
const { patient_authorization_id, consent_id, external_id, mode } = data;
// Queue for async processing - don't block the webhook response
// Fetch FHIR data and process in a background job
await queueDataSync({
patientAuthorizationId: patient_authorization_id,
consentId: consent_id,
externalId: external_id,
mode: mode,
eventId: event_id,
});
}
// Acknowledge receipt immediately
res.status(200).send('OK');
});
#sync_failed
Triggered when Flexpa API fails to synchronize a patient's data from their health plan.
This event fires when a sync job encounters an unrecoverable error, such as authorization failures, payer outages, or timeout issues.
Use this event to:
- Alert users that their data couldn't be retrieved
- Prompt users to re-authenticate if their credentials expired
- Log failures for debugging and support purposes
Payload fields
- event_id
Unique identifier for this event (UUID v4).
Store this to implement idempotent processing, as Flexpa may send the same event multiple times due to retries.
- event
Always "sync_failed".
- version
Payload schema version (currently 1).
- timestamp
Unix timestamp in milliseconds when the event occurred.
- data.mode
The mode for this authorization: "test" or "live". This will always match the mode of the webhook that received the event.
- data.external_id
Your application's user identifier for this patient, if one was passed through the Consent SDK. This field is optional and is omitted from the payload when no external_id was supplied at session creation.
- data.consent_id
The consent ID associated with this patient authorization.
A consent represents a single user session in Flexpa Consent and may result in multiple patient authorizations.
Failed syncs do not necessarily mean data is unavailable. If there was a previous successful sync, you can still query that data via the FHIR API. The failure indicates the most recent sync attempt did not complete.
Handling sync_failed
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-flexpa-signature'];
const payload = req.body.toString('utf8');
// Verify signature (see verifyWebhookSignature above)
if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event_id, event, data } = JSON.parse(payload);
// Check for duplicate events (see isDuplicateEvent in sync_completed example)
if (await isDuplicateEvent(event_id)) {
return res.status(200).send('Already processed');
}
if (event === 'sync_failed') {
const { patient_authorization_id, consent_id, external_id } = data;
// Notify user that sync failed
await notifyUser(external_id, {
message: 'Unable to retrieve your health data',
action: 'Please try reconnecting your account',
});
// Log for monitoring
console.error('Sync failed', {
eventId: event_id,
patientAuthorizationId: patient_authorization_id,
consentId: consent_id,
externalId: external_id,
});
}
res.status(200).send('OK');
});
#refresh_expired
Triggered when Flexpa API can no longer refresh a patient authorization. This fires when the refresh token has expired or the connection has failed repeatedly with no remaining retries. No further sync events will fire for this authorization until the user re-consents through Flexpa Consent.
Use this event to:
- Prompt the affected user to re-consent through Flexpa Consent
- Mark the patient authorization as stale in your own system
- Stop expecting fresh data for this authorization
Payload fields
- event_id
Unique identifier for this event (UUID v4).
Store this to implement idempotent processing, as Flexpa may send the same event multiple times due to retries.
- event
Always "refresh_expired".
- version
Payload schema version (currently 1).
- timestamp
Unix timestamp in milliseconds when the event occurred.
- data.mode
The mode for this authorization: "test" or "live". This will always match the mode of the webhook that received the event.
- data.external_id
Your application's user identifier for this patient, if one was passed through the Consent SDK. This field is optional and is omitted from the payload when no external_id was supplied at session creation.
- data.consent_id
The consent ID associated with this patient authorization.
Previously synced data remains queryable via the FHIR API immediately after this event. For applications with autoExpunge enabled (the default), the patient's data is deleted roughly 24 hours after the last successful sync — so prompt the user to re-consent promptly to avoid losing access.
Handling refresh_expired
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-flexpa-signature'];
const payload = req.body.toString('utf8');
// Verify signature (see verifyWebhookSignature above)
if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event_id, event, data } = JSON.parse(payload);
// Check for duplicate events (see isDuplicateEvent in sync_completed example)
if (await isDuplicateEvent(event_id)) {
return res.status(200).send('Already processed');
}
if (event === 'refresh_expired') {
const { patient_authorization_id, external_id } = data;
// Prompt the user to re-consent
await notifyUser(external_id, {
message: 'Please re-consent to keep your health plan connected.',
action: 'reconsent',
});
}
res.status(200).send('OK');
});
#endpoint_status_changed
Triggered when a Flexpa API-registered endpoint's connectivity status changes — for example, when a payer endpoint that was previously reachable starts failing health checks, or when a broken endpoint recovers.
Use this event to monitor the health of the endpoints your users connect through and to alert your team when a payer's connectivity degrades.
endpoint_status_changed is delivered to every webhook registered in the matching mode (test or live), regardless of which workspace registered it. Filter on payload.data.endpoint_id if you only care about specific endpoints.
Payload fields
- event_id
Unique identifier for this event (UUID v4).
Store this to implement idempotent processing, as Flexpa may send the same event multiple times due to retries.
- event
Always "endpoint_status_changed".
- version
Payload schema version (currently 1).
- timestamp
Unix timestamp in milliseconds when the event occurred.
- data.mode
The mode for this event: "test" or "live". This will always match the mode of the webhook that received the event.
- data.endpoint_id
The Flexpa endpoint ID whose status changed. Stable across status transitions.
- data.previous_status
The status the endpoint had before this transition. null only on the first observation of an endpoint, which is rare.
- data.new_status
The status the endpoint has after this transition.
- data.organization
Snapshot of the parent organization at event time, with id, name, type, and status fields.
The name and status reflect what was true when the transition occurred, not when the webhook is delivered.
#Transitions you'll see
CONNECTED → BROKEN: a previously healthy endpoint started failing health checks.
BROKEN → CONNECTED: a broken endpoint recovered.
CONNECTED → UNAVAILABLE: an endpoint was taken offline (e.g., by the payer or for maintenance).
UNAVAILABLE → CONNECTED: an unavailable endpoint came back online.
null → CONNECTED: the first observation of a newly registered endpoint.
Handling endpoint_status_changed
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-flexpa-signature'];
const payload = req.body.toString('utf8');
// Verify signature (see verifyWebhookSignature above)
if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event_id, event, data } = JSON.parse(payload);
// Check for duplicate events (see isDuplicateEvent in sync_completed example)
if (await isDuplicateEvent(event_id)) {
return res.status(200).send('Already processed');
}
if (event === 'endpoint_status_changed') {
const { endpoint_id, previous_status, new_status, organization } = data;
// Filter to endpoints you actually care about
if (!isWatchedEndpoint(endpoint_id)) {
return res.status(200).send('OK');
}
// Page on-call when a healthy endpoint breaks
if (previous_status === 'CONNECTED' && new_status === 'BROKEN') {
await pageOnCall({
endpointId: endpoint_id,
organizationName: organization.name,
organizationType: organization.type,
});
}
// Auto-resolve when an endpoint recovers
if (new_status === 'CONNECTED') {
await resolveAlerts(endpoint_id);
}
}
res.status(200).send('OK');
});
#Retry behavior
If your webhook endpoint returns a non-2xx status code or fails to respond within 30 seconds, Flexpa will automatically retry the delivery using exponential backoff.
Each delivery is attempted up to 5 times in total — the initial attempt plus up to 4 retries — with increasing delays between attempts (2 minutes, 4 minutes, 8 minutes, 16 minutes).
After the 5th failed attempt, Flexpa stops retrying and marks the delivery as failed.
Implement idempotent webhook handling using the event_id field to safely process duplicate events from retries.
#Best practices
- Return quickly: Acknowledge receipt immediately with a 2xx status code and process the webhook asynchronously
- Be idempotent: Use
event_id to detect and skip duplicate events
- Verify signatures: Always validate the
X-Flexpa-Signature header before processing
- Handle errors gracefully: Return 2xx for successfully received events, even if your internal processing fails
- Log events: Keep a record of received webhooks for debugging and auditing
#FAQ
#Why isn't my webhook being received?
Verify your endpoint URL is correct and publicly accessible.
Check that your server is running and that firewall rules aren't blocking incoming requests from Flexpa.
Ensure your endpoint returns a 2xx status code.
#Why is signature verification failing?
Ensure you're using the correct webhook secret.
Verify you're constructing the signed payload correctly as {timestamp}.{request_body}.
Make sure you're using the raw request body string, not parsed JSON.
#How do I test webhooks in test mode?
When you trigger a sync in test mode, Flexpa will send webhook notifications to your test mode webhook endpoint.
Make sure you've configured a separate webhook for test mode, as webhooks registered for live mode will not receive test mode events.
Use the test credentials and flows described in the test mode documentation to trigger sync events.
#What happens if my endpoint is down during a webhook delivery?
Flexpa will automatically attempt delivery up to 5 times total (the initial attempt plus up to 4 retries) using exponential backoff.
If all attempts fail, the delivery is marked as failed.
#Need help?
If you're experiencing issues with webhooks, contact support with your application ID, an example webhook payload (including the event_id), and the destination URL and approximate time of a failed delivery.