Skip to main content

Webhooks

Blank delivers events to your HTTPS endpoint as signed CloudEvents 1.0 JSON. Webhooks replace polling for anything you need to react to promptly. Webhooks need an API key. Creating, updating, deleting, rotating and replaying require a key with webhooks:write; reading endpoints and deliveries requires webhooks:read. This holds even when every read you care about is anonymous, such as staking or token data.

Limits

Deleted endpoints do not count toward the endpoint limit. The per-batch figure is a fair-share bound rather than a quota: dispatch takes at most that many of your deliveries per pass so one account’s backlog cannot delay everyone else’s, and your remaining deliveries are picked up on subsequent passes in the usual order.

Operations

All mutations require an Idempotency-Key. Update, delete, and rotate additionally require If-Match with the endpoint’s current version.

Event types

An endpoint can subscribe to a reserved type, and the subscription is accepted, but Blank sends nothing for it until the type ships. Until then, poll the matching read: GET /presales/{id} for presales. The API has no audience export yet. prediction.submitted and operation.failed go only to endpoints owned by the account that submitted the prediction or owns the operation. Every other delivered type is a public market event: every active endpoint subscribed to the type receives it, for every visible token. Hidden tokens send nothing. Types are versioned with a .v1 suffix. A breaking payload change ships as a new type, never as a mutation of an existing one.

Create an endpoint

The signing secret is returned exactly once, at creation and at rotation. Store it in your secret manager immediately. If you lose it, rotate — there is no way to read it back.

URL policy

Endpoint URLs are validated at creation and again on every delivery attempt. A URL is rejected with 422 webhook_url_disallowed unless it:
  • uses https: on the default port (no port, or 443)
  • carries no userinfo and no fragment
  • resolves to a public, fully qualified hostname — not localhost, and not a hostname ending in .localhost, .local, .internal, .home.arpa, or .onion
  • is not a private, loopback, link-local, carrier-grade NAT, documentation, or multicast IP address, in IPv4 or IPv6
Redirects are followed at most three times, and every hop is re-validated against the same policy.

The delivery request

Blank sends POST with these headers: The body is a CloudEvent:
data carries the allowlisted payload for that event type. Treat unknown fields as additive and ignore them.

Event payloads

build.blank.trade.confirmed.v1

Sent once for each trade Blank indexes on a visible token. subject is token_trade/{id}, and data is the same Trade object that GET /tokens/{mint}/trades returns. A trade the indexer sees again, for example after a retry, sends nothing.

build.blank.staking.position.updated.v1

Sent when Blank indexes a new staking position, a change to one, or its removal. subject is staking_position/{id}. data.change says which: data.position is the StakingPosition object that blank.staking.walletPositions() returns, as of the change, without the reward fields (pendingRewardsLamports, totalRewardsClaimedLamports, claimable, rewardsAsOf). Rewards are read live from chain, so call walletPositions() when you need them. A closed position carries its last indexed values with amountRaw and weightedAmountRaw set to "0", and updatedAt set to the time it was removed. Re-indexing a position without a change sends nothing.

build.blank.launch.created.v1 and build.blank.launch.confirmed.v1

subject is token_launch/{id}, and data is the Token object that GET /tokens/{mint} returns at the moment of the change. Each is sent at most once per token. A launch that is still waiting for its creator’s signature sends nothing: it may never reach the chain. A token that is hidden when it launches sends neither event.

build.blank.fee.distribution.created.v1

Sent once for each fee distribution Blank records on a visible token. subject is fee_distribution/{id}, and data is the item that GET /tokens/{mint}/fee-distributions returns, plus the token’s mintAddress, since the item alone does not name the token. A distribution the indexer sees again sends nothing.

build.blank.prediction.round.locked.v1 and build.blank.prediction.round.settled.v1

subject is prediction_round/{id}, and data is the PredictionRound object that GET /tokens/{mint}/prediction-rounds/{roundId} returns. round.locked is sent once when the round stops taking predictions, and round.settled once when its settlement price and ranks are final. A settlement that fails and is retried does not send a second round.locked. predictionCount in round.locked counts the predictions recorded when the round locked.

build.blank.operation.failed.v1

Sent once when one of your asynchronous operations fails, and only to endpoints owned by the account that owns the operation. subject is api_operation/{id}, and data is the Operation object that GET /operations/{operationId} returns, with the Problem Details of the failure in error.
Events are dispatched about once a minute and can arrive out of order. To rebuild state, order staking events for the same position by data.position.updatedAt, trades by data.blockTime, and fee distributions by data.collectedAt, and deduplicate on Blank-Event-Id. When you need the latest state rather than the history, read the resource from the API.

Verifying the signature

Blank-Signature is an HMAC-SHA-256 over "{timestamp}.{raw request body}", hex-encoded. Verify the exact raw bytes before parsing JSON — re-serializing the body changes the signature.
verifyWebhookSignature enforces a timestamp tolerance of 300 seconds by default, rejecting replays outside that window. Override it with toleranceSeconds (1 to 3,600) only if you have a reason to. It checks every v1 signature against secret, and every v0 signature against previousSecret when you supply one.
Do not implement string comparison of signatures by hand. If you must verify outside the SDK, use a constant-time comparison and reject any request whose timestamp is outside your tolerance window.

Responding

  • Return any 2xx only after the event is durably accepted. Write it to a queue or a table first, then acknowledge.
  • Anything other than 2xx, a timeout, or a transport failure counts as a failed attempt and is retried.
  • The delivery timeout is 10 seconds. Do the work asynchronously.
  • Deduplicate on Blank-Event-Id. Retries and replays reuse the same event ID with a new delivery ID, and at-least-once delivery is the contract.

Retries and dead letters

Each delivery gets up to 7 attempts: the first, then six retries at roughly 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 24 hours. Each delay is jittered, and a Retry-After header on your response is honoured up to 24 hours. Delivery status moves through:

Inspecting deliveries

Delivery fields: id, endpointId, eventId, eventType, status, attemptCount, nextAttemptAt, responseStatus, lastErrorCode, deliveredAt, createdAt, updatedAt. Filter by status and paginate with cursor and limit. lastErrorCode distinguishes transport failures (request_timeout, network_error, url_disallowed, redirect_limit_exceeded, redirect_location_missing) from HTTP rejections, which are recorded as http_<status>. The complete stable list is in Errors.

Replaying

A replay creates a fresh delivery for the same immutable event, with a new Blank-Delivery-Id and the original Blank-Event-Id. Use it to drain dead letters after fixing your endpoint. A delivery that is not in a replayable state returns 409 webhook_delivery_not_replayable; an unknown or foreign delivery returns 404 webhook_delivery_not_found.

Rotating the signing secret

Rotation issues a new secret and keeps the previous one valid for a bounded overlap window, so you can deploy without dropping in-flight deliveries.
overlapHours is an integer from 1 to 24, defaulting to 24. During the overlap, Blank signs each delivery with both secrets: v1 with the new secret and v0 with the previous one. Safe rotation:
  1. Rotate and store the new secret as BLANK_WEBHOOK_SECRET.
  2. Move the old value to BLANK_PREVIOUS_WEBHOOK_SECRET and deploy, so your handler accepts both.
  3. Once the overlap window closes, remove BLANK_PREVIOUS_WEBHOOK_SECRET.

Updating and deleting

Both use optimistic concurrency:
update accepts any non-empty subset of name, url, eventTypes, and status (active or disabled). A stale version returns 412 precondition_failed — re-read the endpoint and retry. delete soft-deletes the endpoint, stops all future fanout, and returns { id, deletedAt }.

Errors

Shared errors apply too. See Errors and Conventions.