Skip to main content

Webhooks

Webhooks tell your server when an Account, a claim or a Verification changes, so that you do not have to poll the Partner API for changes. Each delivery is a signed HTTPS POST that identifies the resource and what happened to it. To read the details, query the Partner API.

OperationUse it to
webhookEventTypesList the event types that an endpoint can subscribe to.
createWebhookEndpointRegister your receiver and get its signing secret.
sendTestWebhookSend a test delivery to an endpoint.
webhookEndpointsList your endpoints and their health.
updateWebhookEndpointChange an endpoint's URL, event types or label, or pause it.
rotateWebhookSigningSecretReplace an endpoint's signing secret.
deleteWebhookEndpointStop sending to an endpoint and remove it.
webhookDeliveriesList the deliveries to an endpoint.
webhookDeliveryRead one delivery and each attempt to send it.
redeliverWebhookSend one delivery again.
redeliverWebhooksSend again the deliveries to an endpoint in a range of time.

A typical integration:

  1. Create an endpoint and store its signing secret.
  2. Send a test and confirm that your receiver verifies the signature.
  3. Ask First Dollar to enable live events.
  4. When a delivery arrives, find the resource and read its current state.

Webhooks use your Partner API credentials. There are no separate credentials to request.

Enabling live events​

Enabling webhooks

Live events are not enabled by default. Contact [email protected] to enable them. You can create endpoints and send test deliveries before live events are enabled.

Until live events are enabled, events for your endpoints are recorded with the status SKIPPED and are not sent. This includes retries that were already scheduled. When live events are enabled, First Dollar does not send the skipped events automatically. To receive them, use redeliverWebhooks with status: SKIPPED.

Claim events also need claims operations to be enabled for you. Until both are enabled, claim events are recorded as SKIPPED in the same way.

Test deliveries and webhook_endpoint.disabled events do not need live events to be enabled.

Event types​

TypeSent when
participant.enrollment.createdAn enrollment has an Account. Sent once for each Account.
participant.enrollment.endedAn enrollment's end date is set or changed to a new date. Sent once for each change. Removing an end date sends no event.
hsa_account.openedAn HSA has an Account.
hsa_account.closedAn HSA is closed.
claim.createdA reimbursement claim is created. Sent once for each claim.
claim.status_changedA claim's status changes, for example to APPROVED, REJECTED or PAID.
claim.updatedA claim's amount, dates, merchant, provider, Benefit or document group changes.
verification.status_changedA Verification is created, or its status changes.
webhook_endpoint.disabledOne of your endpoints is disabled after repeated failures.

The webhookEventTypes query returns the current list.

An endpoint receives an event only if the endpoint was subscribed to that event type when the event occurred. Adding an event type to an endpoint does not send earlier events of that type.

Events cover your members' Accounts, claims and Verifications, including those created outside the Partner API, for example in the First Dollar app.

Claim events​

Claim events also need claims operations to be enabled. See The claim lifecycle for what each status means.

  • A new claim sends only claim.created. Each later change of its status sends claim.status_changed.
  • claim.updated is sent when amount, transactionDate, serviceStartDate, serviceEndDate, merchant, provider, benefitId or documentGroupId changes, for example after updateClaim. A change of claimantId or description sends no event.

Verification events​

A change to a Verification that leaves its status the same sends no event. See Verification status for what each status means.

Find the resource for an event​

data.resourceType tells you what data.resourceId identifies:

resourceTypeEvent typesresourceId is the id ofRead it with
accountparticipant.*, hsa_account.*an AccountIndividual.healthWallet.accounts. See Accounts.
claimclaim.*a Claimclaim with where: { id }. It includes individualId.
verificationverification.status_changeda Verificationverification with where: { id }. See Verifications.
webhook_endpointwebhook_endpoint.disabled, webhook.testa WebhookEndpointwebhookEndpoints.

Accounts​

The Partner API does not look up an Account, including an HSA, by its id. To find the Individual for an event:

  • Keep a map of Account id to Individual id. Build it when you first load your Individuals.
  • When an Account is not in your map, as for most participant.enrollment.created and hsa_account.opened events, read the Individuals that you enrolled recently first. If the Account is not there, page through the individuals query and match the Account id. For many new Accounts, refresh the map in one pass rather than once for each event.

Then read the Individual's current Accounts. To include ended enrollments, do not filter the wallet by status. The Account fields describe enrollment status; there is no separate field for HSA closure.

Verifications​

A Verification does not include its Individual. To find the Individual for an event:

  • Keep a map of Verification id to Individual id. Build it from Individual.verifications when you first load your Individuals, and add to it each time verifyIndividual returns a Verification.
  • When a Verification is not in your map, as for one that an Individual started in the First Dollar app, page through the individuals query and match the id in Individual.verifications. For many new Verifications, refresh the map in one pass rather than once for each event.

Create an endpoint​

mutation {
createWebhookEndpoint(
input: {
url: "https://hooks.example.com/first-dollar"
eventTypes: ["participant.enrollment.created", "hsa_account.opened"]
description: "Production receiver"
}
) {
endpoint {
id
url
eventTypes
status
}
signingSecret
}
}
Sample Response
{
"data": {
"createWebhookEndpoint": {
"endpoint": {
"id": "01JABCDEFGHJKMNPQRSTVWXYZ0",
"url": "https://hooks.example.com/first-dollar",
"eventTypes": ["participant.enrollment.created", "hsa_account.opened"],
"status": "ACTIVE"
},
"signingSecret": "whsec_<base64-encoded-key>"
}
}
}
Store the signing secret

signingSecret is returned only when you create an endpoint or rotate its secret. Queries never return it. Store it in your secret manager. If you lose it, rotate the secret.

The URL must:

  • Use https on port 443.
  • Use a hostname that you control and that resolves only to public IP addresses. An IP address in the URL must also be public.
  • Not contain a username, password or fragment (#).
  • Be at most 2048 characters.

First Dollar resolves the hostname again for each attempt. If it resolves to an address that is not public, the attempt fails with BLOCKED_ADDRESS and is not retried. First Dollar does not follow redirects. Hostnames under the default domains of Google Cloud services, such as *.run.app, *.cloudfunctions.net and *.appspot.com, are refused. To receive webhooks on such a service, map a custom domain to it.

Do not put tokens or other credentials in the URL or its query string. The signature authenticates each delivery, and the URL is returned in full by endpoint queries.

Other limits:

  • A partner can have at most 20 endpoints.
  • Each endpoint must have a different URL. To receive more event types at the same URL, add them to the existing endpoint.
  • An endpoint subscribes to between 1 and 20 event types.
  • description is your label for the endpoint, at most 500 characters.

Send a test​

Call sendTestWebhook on an ACTIVE endpoint. It sends a webhook.test event through the same delivery path, signing and retries as a live event. A test does not need a subscription, and it is sent even before live events are enabled.

mutation {
sendTestWebhook(endpointId: "01JABCDEFGHJKMNPQRSTVWXYZ0") {
id
status
}
}

The mutation returns the delivery at once, with the status PENDING. To see the result, query webhookDelivery with the delivery id.

In a test, data.resourceType is webhook_endpoint and data.resourceId is the endpoint id.

Receive a delivery​

Each delivery is a POST with a JSON body and these headers:

HeaderValue
webhook-idThe delivery id. It is the same on every retry and redelivery.
webhook-timestampWhen this attempt was signed, in seconds since the Unix epoch.
webhook-signatureOne or more space-separated signatures, each in the form v1,<base64-signature>.
content-typeapplication/json
Sample Body
{
"id": "01JABCDEFGHJKMNPQRSTVWXYZ1",
"type": "hsa_account.opened",
"occurredAt": "2026-10-23T12:00:00.000Z",
"data": {
"resourceType": "account",
"resourceId": "<account-id>"
}
}
FieldDescription
idThe delivery id, the same value as the webhook-id header.
typeThe event type.
occurredAtWhen the event occurred, as an ISO 8601 timestamp in UTC.
data.resourceTypeThe type of resource: account, claim, verification or webhook_endpoint. See Find the resource for an event.
data.resourceIdThe id of that resource.

The body contains only identifiers and a time. It contains no member details, balances or claim data.

Verify the signature​

Verify the signature before you parse or act on the body. Deliveries follow the Standard Webhooks specification, so you can use a maintained Standard Webhooks library for your language. To verify a delivery yourself:

  1. Read the webhook-id, webhook-timestamp and webhook-signature headers.
  2. Reject the delivery if webhook-timestamp is more than five minutes from your current time. Keep your server clock synchronized.
  3. Remove the whsec_ prefix from the signing secret, and base64-decode the rest. The result is the key.
  4. Compute an HMAC-SHA256 with the key over webhook-id, a period, webhook-timestamp, a period and the raw request body.
  5. Base64-encode the result. Compare it with the signature in each v1, entry of webhook-signature, with a constant-time comparison. Accept the delivery if any entry matches.

Use the raw request body exactly as received. If you parse the JSON and serialize it again, the bytes can change, and the signature does not match.

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

// rawBody is the request body as a Buffer, before any JSON parsing.
export function isValidWebhook(rawBody, headers, signingSecret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) {
return false;
}
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(signingSecret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) {
return false;
}
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

Respond​

Return a 2xx status after you store the event durably. Do the work that the event starts after you respond.

  • Time limits. First Dollar waits five seconds to connect, and fifteen seconds for the whole attempt, from the hostname lookup to the complete response. A slower attempt fails with TIMEOUT.
  • Duplicates. Delivery is at least once, so the same event can arrive more than once. Use webhook-id to ignore a delivery that you already processed.
  • Order. Events can arrive out of order, and some arrive later than others. Do not use the order of arrival to decide the current state. Read the resource from the Partner API before you update your records.
  • Response bodies. First Dollar stores the first 1 KiB of the response body of a failed attempt so that you can read it in webhookDelivery. Do not return credentials or personal data in a response body.

Retries​

First Dollar retries an attempt that fails with:

  • a timeout or a connection failure
  • HTTP 408, 429 or any 5xx status

A retryable failure is retried up to seven times, after these intervals:

RetryInterval
11 minute
25 minutes
330 minutes
42 hours
56 hours
612 hours
724 hours

Each interval is increased by a random amount of up to 20%, but not past 24 hours. With a 429 or 503 response, a valid Retry-After header sets the minimum interval, and that interval can be longer than 24 hours.

If your receiver keeps failing, retries stop when the endpoint is disabled, at most 24 hours after the first failure. A retry that comes due after that is SKIPPED. The later retries in the table run only if other attempts to the endpoint succeed in the meantime.

Any other HTTP status, including a redirect or a 4xx other than 408 and 429, finishes the delivery as FAILED with no retry. A TLS failure, or a hostname that resolves to an address that is not public, also finishes the delivery with no retry.

Automatic disabling​

First Dollar sets an endpoint's status to DISABLED when either:

  • ten deliveries in a row finish as FAILED, or
  • 24 hours pass after a failed attempt with no successful attempt since.

A successful attempt resets the failure counts and failingSince. Because the 24-hour rule counts from a single failed attempt, an endpoint that receives few events can be disabled 24 hours after one failure, including a failed test. A long Retry-After does not delay automatic disabling.

The WebhookEndpoint fields consecutiveFailedDeliveries, consecutiveFailedAttempts and failingSince show how close an endpoint is to being disabled. Monitor them, and fix your receiver before they reach the limits.

When an endpoint is disabled, First Dollar sends a webhook_endpoint.disabled event to each of your ACTIVE endpoints that is subscribed to it, and to the disabled endpoint if it is subscribed. data.resourceId is the id of the disabled endpoint. Its delivery to the disabled endpoint does not change that endpoint's failure counts or status. An endpoint that fails other events can often still accept this one. To receive it when one receiver is down, subscribe more than one endpoint.

Other than that event, a DISABLED endpoint receives no events. Any attempt that comes due while it is disabled is SKIPPED, including retries of earlier events. To enable the endpoint again, fix your receiver and then set its status to ACTIVE. This also resets its failure counts. Then redeliver the SKIPPED and FAILED deliveries that you need.

Manage endpoints​

Change an endpoint​

updateWebhookEndpoint changes only the fields that you pass. eventTypes replaces the full list of subscribed types. A new url must meet the same rules, and later attempts, including scheduled retries, go to the new URL.

mutation {
updateWebhookEndpoint(
input: {
endpointId: "01JABCDEFGHJKMNPQRSTVWXYZ0"
eventTypes: ["participant.enrollment.created", "participant.enrollment.ended", "hsa_account.opened"]
}
) {
id
eventTypes
}
}

Pause or resume an endpoint​

Set status: PAUSED to stop deliveries, for example during maintenance of your receiver. Any attempt that comes due while an endpoint is paused is SKIPPED, including retries of earlier events. Set status: ACTIVE to resume. Resuming does not send the skipped deliveries; redeliver them if you need them.

Rotate the signing secret​

rotateWebhookSigningSecret returns a new signingSecret. For the next 24 hours, each delivery is signed with both the new and the previous secret, so webhook-signature has two entries. After 24 hours, only the new secret is used.

To rotate:

  1. Call rotateWebhookSigningSecret and store the new secret.
  2. Switch your receiver to the new secret before the 24 hours end. Until then, every delivery has a signature from each secret, so you can switch at any time.

If a secret leaked, rotate once and switch your receiver to the new secret at once. Your receiver then rejects any request that is signed only with the leaked secret.

If you rotate again within 24 hours, the oldest secret stops at once. A receiver that still uses that secret rejects the next delivery.

Delete an endpoint​

deleteWebhookEndpoint stops all sends to the endpoint. Its pending deliveries are skipped. The endpoint and its deliveries no longer appear in queries, and its deliveries cannot be redelivered. You cannot restore a deleted endpoint, but you can create a new endpoint with the same URL.

Inspect deliveries​

webhookDeliveries lists the deliveries to one endpoint, newest first. You can filter by status, by event type and by occurredAt with from and through. Pass from and through as ISO 8601 timestamps with a time zone, such as 2026-10-20T00:00:00Z. webhookEndpoints lists your endpoints, newest first. Both return nodes and pageInfo, and accept page: { first, after }. first is at most 200, and 50 by default. See Pagination.

Delivery statusMeaning
PENDINGAn attempt is due, in progress, or waiting for a retry. See nextAttemptAt.
SUCCEEDEDYour receiver returned a 2xx status.
FAILEDA response that is not retried, or every retry failed.
SKIPPEDNot sent, because the endpoint was paused, disabled or deleted when an attempt was due, live events or claims operations were not enabled, or First Dollar suspended deliveries.

To see each attempt, query one delivery with webhookDelivery and select attempts:

query {
webhookDelivery(deliveryId: "01JABCDEFGHJKMNPQRSTVWXYZ1") {
id
type
status
nextAttemptAt
attempts {
attemptNumber
responseStatus
fault
latencyMs
responseBody
createdAt
}
}
}

attempts is available only on webhookDelivery. Selecting it on webhookDeliveries returns BAD_USER_INPUT.

faultMeaning
TIMEOUTThe connection or the response took longer than the time limit.
CONNECTIONThe connection failed or closed before a response arrived.
TLSThe TLS handshake or certificate check failed.
BLOCKED_ADDRESSThe URL is not allowed, or its hostname resolved to an address that is not public.
HTTP_STATUSYour receiver returned a status that is not 2xx. See responseStatus.
nullThe attempt succeeded.

responseBody is the first 1 KiB of the response to a failed attempt. It is null for a successful attempt.

Redeliver​

Redelivery starts a new cycle of attempts and retries for a delivery. The delivery keeps its id, so webhook-id is the same and your duplicate check still applies. The endpoint must be ACTIVE.

Redeliver one delivery​

redeliverWebhook accepts a delivery that is SUCCEEDED, FAILED or SKIPPED. It also accepts a PENDING delivery whose next retry is more than five minutes away, for example after a long Retry-After. It returns INVALID_STATE for a delivery with an attempt that is due or in progress.

Redeliver a range​

redeliverWebhooks sends again the deliveries to one endpoint that have a given status and an occurredAt from from through through. status must be SUCCEEDED, FAILED or SKIPPED. Each call handles at most limit deliveries, up to 100 and 50 by default, newest occurredAt first.

mutation {
redeliverWebhooks(
endpointId: "01JABCDEFGHJKMNPQRSTVWXYZ0"
status: SKIPPED
from: "2026-10-20T00:00:00Z"
through: "2026-10-23T00:00:00Z"
) {
deliveries {
id
status
}
hasMore
endCursor
}
}

While hasMore is true, call again with the same arguments and after set to endCursor. The cursor moves past the deliveries that were already sent again, so no delivery is sent twice in the same run.

To send one event again, use redeliverWebhook with its delivery id instead of a range. The occurredAt in a request body is rounded to milliseconds, so a range that ends exactly at that value can miss the event.

Rate limits​

These mutations have lower rate limits than other operations, because each call can send a request to your receiver or issue a secret. As for other operations, each mutation has its own count for each set of API credentials.

MutationLimit
createWebhookEndpoint20 calls per hour
rotateWebhookSigningSecret20 calls per hour
sendTestWebhook10 calls per 60 seconds
redeliverWebhook60 calls per 60 seconds
redeliverWebhooks10 calls per 60 seconds

Errors​

extensions.codeCause
BAD_USER_INPUTThe input is not valid, such as a URL that is not allowed, an unknown event type, a duplicate URL or a page size out of range. The message describes the problem.
NOT_FOUNDNo endpoint or delivery with that id belongs to you.
INVALID_STATEThe endpoint or delivery is not in a state that allows the operation, such as a test or redelivery to an endpoint that is not ACTIVE. Two rotations of the same secret at the same time also return it; retry the rotation.
RATE_LIMITEDThe call is over its rate limit. Wait until extensions.resetAt and try again.