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.
| Operation | Use it to |
|---|---|
| webhookEventTypes | List the event types that an endpoint can subscribe to. |
| createWebhookEndpoint | Register your receiver and get its signing secret. |
| sendTestWebhook | Send a test delivery to an endpoint. |
| webhookEndpoints | List your endpoints and their health. |
| updateWebhookEndpoint | Change an endpoint's URL, event types or label, or pause it. |
| rotateWebhookSigningSecret | Replace an endpoint's signing secret. |
| deleteWebhookEndpoint | Stop sending to an endpoint and remove it. |
| webhookDeliveries | List the deliveries to an endpoint. |
| webhookDelivery | Read one delivery and each attempt to send it. |
| redeliverWebhook | Send one delivery again. |
| redeliverWebhooks | Send again the deliveries to an endpoint in a range of time. |
A typical integration:
- Create an endpoint and store its signing secret.
- Send a test and confirm that your receiver verifies the signature.
- Ask First Dollar to enable live events.
- 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
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
| Type | Sent when |
|---|---|
participant.enrollment.created | An enrollment has an Account. Sent once for each Account. |
participant.enrollment.ended | An 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.opened | An HSA has an Account. |
hsa_account.closed | An HSA is closed. |
claim.created | A reimbursement claim is created. Sent once for each claim. |
claim.status_changed | A claim's status changes, for example to APPROVED, REJECTED or PAID. |
claim.updated | A claim's amount, dates, merchant, provider, Benefit or document group changes. |
verification.status_changed | A Verification is created, or its status changes. |
webhook_endpoint.disabled | One 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 itsstatussendsclaim.status_changed. claim.updatedis sent whenamount,transactionDate,serviceStartDate,serviceEndDate,merchant,provider,benefitIdordocumentGroupIdchanges, for example after updateClaim. A change ofclaimantIdordescriptionsends 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:
resourceType | Event types | resourceId is the id of | Read it with |
|---|---|---|---|
account | participant.*, hsa_account.* | an Account | Individual.healthWallet.accounts. See Accounts. |
claim | claim.* | a Claim | claim with where: { id }. It includes individualId. |
verification | verification.status_changed | a Verification | verification with where: { id }. See Verifications. |
webhook_endpoint | webhook_endpoint.disabled, webhook.test | a WebhookEndpoint | webhookEndpoints. |
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
idto Individualid. Build it when you first load your Individuals. - When an Account is not in your map, as for most
participant.enrollment.createdandhsa_account.openedevents, read the Individuals that you enrolled recently first. If the Account is not there, page through the individuals query and match the Accountid. 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
idto Individualid. Build it fromIndividual.verificationswhen 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
idinIndividual.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
}
}
{
"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>"
}
}
}
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
httpson port443. - 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.
descriptionis 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:
| Header | Value |
|---|---|
webhook-id | The delivery id. It is the same on every retry and redelivery. |
webhook-timestamp | When this attempt was signed, in seconds since the Unix epoch. |
webhook-signature | One or more space-separated signatures, each in the form v1,<base64-signature>. |
content-type | application/json |
{
"id": "01JABCDEFGHJKMNPQRSTVWXYZ1",
"type": "hsa_account.opened",
"occurredAt": "2026-10-23T12:00:00.000Z",
"data": {
"resourceType": "account",
"resourceId": "<account-id>"
}
}
| Field | Description |
|---|---|
id | The delivery id, the same value as the webhook-id header. |
type | The event type. |
occurredAt | When the event occurred, as an ISO 8601 timestamp in UTC. |
data.resourceType | The type of resource: account, claim, verification or webhook_endpoint. See Find the resource for an event. |
data.resourceId | The 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:
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders. - Reject the delivery if
webhook-timestampis more than five minutes from your current time. Keep your server clock synchronized. - Remove the
whsec_prefix from the signing secret, and base64-decode the rest. The result is the key. - Compute an HMAC-SHA256 with the key over
webhook-id, a period,webhook-timestamp, a period and the raw request body. - Base64-encode the result. Compare it with the signature in each
v1,entry ofwebhook-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.
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-idto 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,429or any5xxstatus
A retryable failure is retried up to seven times, after these intervals:
| Retry | Interval |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 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:
- Call
rotateWebhookSigningSecretand store the new secret. - 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 status | Meaning |
|---|---|
PENDING | An attempt is due, in progress, or waiting for a retry. See nextAttemptAt. |
SUCCEEDED | Your receiver returned a 2xx status. |
FAILED | A response that is not retried, or every retry failed. |
SKIPPED | Not 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.
fault | Meaning |
|---|---|
TIMEOUT | The connection or the response took longer than the time limit. |
CONNECTION | The connection failed or closed before a response arrived. |
TLS | The TLS handshake or certificate check failed. |
BLOCKED_ADDRESS | The URL is not allowed, or its hostname resolved to an address that is not public. |
HTTP_STATUS | Your receiver returned a status that is not 2xx. See responseStatus. |
null | The 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.
| Mutation | Limit |
|---|---|
createWebhookEndpoint | 20 calls per hour |
rotateWebhookSigningSecret | 20 calls per hour |
sendTestWebhook | 10 calls per 60 seconds |
redeliverWebhook | 60 calls per 60 seconds |
redeliverWebhooks | 10 calls per 60 seconds |
Errors
extensions.code | Cause |
|---|---|
BAD_USER_INPUT | The 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_FOUND | No endpoint or delivery with that id belongs to you. |
INVALID_STATE | The 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_LIMITED | The call is over its rate limit. Wait until extensions.resetAt and try again. |