FlexpaFlexpa
Developer PortalGet a DemoTry it yourself

Guides

  • Home
  • Quickstart
  • Agent guide
  • Claims data guide
  • Financial data guide
  • Parsing FHIR data

Network

  • Network guide
  • Endpoint directory
  • CHPL directoryNew
  • Directory MCP server

Consent

  • OAuth
  • Patient linking
  • Usage patterns
  • Patient access

Records

  • FHIR API
  • Webhooks
    • Verifying signatures
    • Handling events
    • Retry behavior
    • Best practices
    • FAQ
  • DestinationsNew
  • Data Sheet
  • Node SDK
  • SMART Health Links API
  • Terminology
  • Claims to clinicalNew

Misc

  • ChangelogNew
  • Support
  • Flexpa OS
  • We're hiring

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

  1. Extract the timestamp and signature from the header
  2. Construct the signed payload as: {timestamp}.{request_body}
  3. Compute an HMAC-SHA256 hash using your webhook secret
  4. Compare the computed signature with the received signature
  5. 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.patient_authorization_id

The patient authorization ID that completed syncing.

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.patient_authorization_id

The patient authorization ID that failed to sync.

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.patient_authorization_id

The patient authorization ID that can no longer be refreshed.

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.

Status TwitterGitHub

© 2026 Flexpa. All rights reserved.

FHIR® is the registered trademark of Health Level Seven International and its use does not constitute endorsement by HL7.

On this page
  • Available events
  • Setup
  • Verifying signatures
  • Verification steps
  • Handling events
  • sync_completed
  • sync_failed
  • refresh_expired
  • endpoint_status_changed
  • Transitions you'll see
  • Retry behavior
  • Best practices
  • FAQ
  • Why isn't my webhook being received?
  • Why is signature verification failing?
  • How do I test webhooks in test mode?
  • What happens if my endpoint is down during a webhook delivery?
  • Need help?