Skip to content

Webhooks ​

Webhooks let VALR tell your server when something happens on your business's accounts, such as a deposit being credited or a withdrawal changing status. You give us an HTTPS URL and the event types you want, and we send an HTTP POST to that URL for each event. Every delivery is signed, so you can check it came from VALR, and failed deliveries are retried for about two days.

Beta

Webhooks are in beta. Event payloads may gain fields before general availability, so ignore fields you don't recognise rather than failing on them.

Getting started ​

  1. Build an endpoint that accepts POST requests over HTTPS and returns a 2xx status within 3 seconds. See Receiving deliveries.
  2. Create a webhook with Create a webhook, giving your URL and the event types you want. List webhook event types shows what's available.
  3. Store the signing secret from the response. It's shown only once.
  4. Verify the signature on every delivery before you trust it. See Verifying signatures.
  5. Check delivery history with List webhook delivery attempts and Get a webhook event while you build, and whenever you need to troubleshoot.

API keys and scope ​

Managing webhooks needs an API key with the WEBHOOKS_WRITE bundle that isn't limited to specific sub-accounts. A key limited to sub-accounts gets 401 with code -93 from every webhook endpoint except listing event types.

A webhook covers your whole business: it receives events from your primary account and every sub-account. The envelope's subAccount field says which sub-account an event happened on, and is null for your primary account. Sending X-VALR-SUB-ACCOUNT-ID doesn't narrow a webhook to one sub-account.

Limits ​

LimitValue
Webhooks per business20, counting paused webhooks and those created by your team in VALR Business
Event types per webhook1 to 20
URL length2,048 characters
DescriptionUp to 256 characters of plain text
Delivery timeout3 seconds
Delivery attemptsUp to 10, over about 46 hours. See Retries.
Delivery history page sizeUp to 100 attempts

Endpoint requirements ​

When you create or update a webhook, we check its URL:

  • It must use https.
  • Its host must resolve, and only to public addresses. Private, loopback, link-local and other reserved ranges are rejected.
  • It can't contain a username or password, such as https://user:pass@example.com.

We can't add your own headers or credentials to deliveries, so secure your endpoint by verifying the signature.

Deliveries come from the IP address 34.39.42.17. If your endpoint is behind a firewall or an IP allowlist, allow this address. Do this as well as verifying the signature, not instead of it.

The delivery ​

Each delivery is an HTTP POST with a JSON body.

Headers ​

HeaderValue
Content-Typeapplication/json
User-AgentVALR-Webhooks/1.0
Valr-Webhook-Event-IdThe event's ID. The same on every retry, and on every webhook that receives the event.
Valr-Webhook-Event-TypeThe event type, for example FIAT_DEPOSIT_CREDITED
Valr-Webhook-Delivery-Attempt1 for the first attempt, then 2, 3 and so on
Valr-Webhook-TimestampWhen this attempt was signed, in Unix milliseconds
Valr-Webhook-Signaturev1= followed by the signature. See Verifying signatures.

Body ​

Every event has the same envelope:

json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "FIAT_DEPOSIT_CREDITED",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": { "subAccountId": "12345", "label": "Collections" },
  "data": { }
}
FieldDescription
idThe event's ID, as in Valr-Webhook-Event-Id
typeThe event type, as in Valr-Webhook-Event-Type
createdAtWhen the event was created, in Unix milliseconds
apiVersionThe payload version, currently 2026-09-16
subAccountThe sub-account the event happened on, with its subAccountId (a string) and label; null for your primary account
dataThe event's details. Its shape depends on type; see Event types.

Across all payloads:

  • Amounts are strings, such as "1500.00", so no precision is lost. Don't convert them to floating-point numbers.
  • Times are Unix milliseconds.
  • Fields with no value are sent as null rather than left out.

Verifying signatures ​

Check the signature on every delivery before you act on it. Anyone who learns your URL can send it requests; only VALR knows your signing secret.

The signature is an HMAC-SHA256 of the timestamp header, a full stop and the raw request body, keyed with your signing secret:

signature = hex( HMAC_SHA256( key = signingSecret, message = timestamp + "." + rawBody ) )

To verify a delivery:

  1. Read the Valr-Webhook-Timestamp and Valr-Webhook-Signature headers.
  2. Build the string {timestamp}.{rawBody} from the body exactly as received. Don't parse and re-serialise the JSON first: any change in spacing or key order changes the signature.
  3. Compute the HMAC-SHA256 of that string, keyed with the UTF-8 bytes of your signing secret, and encode it as lower-case hex. Use the secret exactly as you were given it; don't base64-decode it.
  4. Compare your result with the value after v1= in Valr-Webhook-Signature, using a constant-time comparison. Ignore any other versions in the header.
  5. Reject the delivery if its timestamp is more than a few minutes from your server's clock (we suggest 5 minutes), so a captured delivery can't be replayed later.

Each retry is signed again with a new timestamp, so its signature differs from the previous attempt's even though the body is the same.

Check your implementation with this example: the secret known-fixed-secret, the timestamp 1700000000000 and the body {"hello":"world"} give the signature v1=715658e15fcd41e22e2fde274c0b14a3c228a5ae7f00cbf91a2a56c2668ca7d7.

javascript
const crypto = require('crypto')
const express = require('express')

const app = express()
const SIGNING_SECRET = process.env.VALR_WEBHOOK_SECRET
const TOLERANCE_MS = 5 * 60 * 1000

// Keep the raw body: the signature is over the exact bytes we sent.
app.post('/valr/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('Valr-Webhook-Timestamp') || ''
  const signature = (req.get('Valr-Webhook-Signature') || '').replace(/^v1=/, '')
  const rawBody = req.body.toString('utf8')

  const sentAt = Number(timestamp)
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > TOLERANCE_MS) {
    return res.status(400).send('stale or missing timestamp')
  }
  const expected = crypto.createHmac('sha256', SIGNING_SECRET).update(`${timestamp}.${rawBody}`).digest('hex')
  // Compare the hex strings' bytes: a forged header can't make timingSafeEqual throw on mismatched lengths
  const signatureBytes = Buffer.from(signature, 'utf8')
  const expectedBytes = Buffer.from(expected, 'utf8')
  const valid = signatureBytes.length === expectedBytes.length && crypto.timingSafeEqual(signatureBytes, expectedBytes)
  if (!valid) return res.status(400).send('invalid signature')

  // Acknowledge first, then process the event asynchronously (for example from a queue).
  res.sendStatus(200)
  handleEvent(JSON.parse(rawBody))
})
python
import hashlib
import hmac
import os
import time

SIGNING_SECRET = os.environ["VALR_WEBHOOK_SECRET"]
TOLERANCE_MS = 5 * 60 * 1000


def is_valid_delivery(headers: dict, raw_body: bytes) -> bool:
    timestamp = headers.get("Valr-Webhook-Timestamp", "")
    signature = headers.get("Valr-Webhook-Signature", "").removeprefix("v1=")
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() * 1000 - sent_at) > TOLERANCE_MS:
        return False
    message = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(SIGNING_SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
    # Compare bytes: compare_digest raises on a str with non-ASCII characters, which a forged header can contain
    return hmac.compare_digest(signature.encode("utf-8"), expected.encode("utf-8"))

Receiving deliveries ​

  • Respond within 3 seconds with any 2xx status. Without a response by then, the attempt counts as a timeout and is retried.
  • Acknowledge first, process later. Verify the signature, store or queue the event and return 200. Do slow work, such as database writes or calls to other services, after responding.
  • Don't redirect. We don't follow redirects, and a 3xx response is a permanent failure.
  • Expect duplicates. Delivery is at least once: a retry can arrive after you've already handled the first attempt, for example if your response was lost. Record the event IDs you've processed and skip repeats.
  • Don't rely on order. Events usually arrive in the order they happened, but that isn't guaranteed, particularly with retries. Use the times in data and, where it matters, read the current state from the API.
  • Ignore unknown event types and fields, so additions don't break your integration.

Retries ​

Your responseWhat happens
2xxDelivered. Nothing more is sent for this event.
408, 429 or any 5xxRetried
No response within 3 seconds, connection refused or reset, DNS or TLS failureRetried
Any other 4xx, or any 3xxPermanent failure, not retried

Each retry waits longer after the previous attempt:

Attempt2345678910
Delay30 s1 min5 min15 min1 h3 h6 h12 h24 h

If the 10th attempt fails, about 46 hours after the first, we stop and mark the event DEAD_LETTER. Use Get a webhook event to fetch its payload if you need to process it yourself.

A webhook is never paused or disabled automatically, however many deliveries fail. If you pause a webhook (set active to false) or delete it, its pending retries stop and aren't resumed when you reactivate it, and events that happen while it's paused aren't delivered.

Delivery history ​

List webhook delivery attempts lists every attempt for a webhook, newest first. Get a webhook event returns one event's payload with all its attempts. Each attempt's terminalState is SUCCESS, PERMANENT_FAILURE or DEAD_LETTER once the event is finished, and null while it's still being retried.

When an attempt fails, errorCategory says why:

CategoryMeaning
HTTP_STATUSYour endpoint returned a status other than 2xx; see responseStatus
TIMEOUTYour endpoint didn't respond within 3 seconds
CONNECTION_REFUSEDYour endpoint refused the connection
CONNECTION_RESETYour endpoint closed the connection unexpectedly
DNS_FAILUREThe host name couldn't be resolved
TLS_FAILUREThe TLS handshake failed. Check that your certificate is valid and hasn't expired.
NETWORK_UNREACHABLEYour endpoint's host couldn't be reached
BLOCKEDThe URL failed our safety checks when sending, for example because its host now resolves to a private address
UNKNOWNSomething else went wrong. Contact support if it keeps happening.

Event types ​

List webhook event types returns the current list.

Event typeSent when
FIAT_DEPOSIT_CREDITEDA bank deposit is credited. reference is the deposit reference it matched, or null.
FIAT_WITHDRAWAL_STATUS_UPDATEA fiat withdrawal changes status
NEW_PENDING_RECEIVEA crypto deposit is detected on one of your deposit addresses
CRYPTO_DEPOSIT_CREDITEDA crypto deposit is confirmed and credited
SEND_STATUS_UPDATEA crypto withdrawal completes or fails
TRANSFERFunds move between your primary account and a sub-account, or between sub-accounts. Sent for the account the funds left.
NEW_ACCOUNT_HISTORY_RECORDAny new entry in an account's transaction history. This can be high volume; subscribe to the specific events above if you only need some of it.

Typical payloads:

FIAT_DEPOSIT_CREDITED
json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "FIAT_DEPOSIT_CREDITED",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": null,
  "data": {
    "depositId": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
    "amount": "1000.00",
    "currencySymbol": "ZAR",
    "reference": "VALRABCDEF",
    "effectiveDate": 1699920000000,
    "creditedAt": 1700000000000
  }
}
FIAT_WITHDRAWAL_STATUS_UPDATE

Use withdrawalId with the fiat withdrawal endpoints for the full details; status takes the same values. When the bank rejects a withdrawal you receive FAILED straight away, then REVERSED once the funds are back in your account. Both include failureReason, failureReasonDescription, failureReasonAction and failureRetryable when a reason is known. REVERSED also has principalReturned, feeReturned (null if the fee wasn't returned) and returnedAt. These fields are null on other updates. Rarely, a FAILED withdrawal returns to PROCESSING.

json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "FIAT_WITHDRAWAL_STATUS_UPDATE",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": null,
  "data": {
    "withdrawalId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
    "status": "REVERSED",
    "currencySymbol": "ZAR",
    "amount": "1500.00",
    "failureReason": "ACCOUNT_CLOSED",
    "failureReasonDescription": "The bank account is closed.",
    "failureReasonAction": "Withdraw to a different bank account.",
    "failureRetryable": false,
    "principalReturned": "1500.00",
    "feeReturned": "8.00",
    "returnedAt": 1700000000000
  }
}
NEW_PENDING_RECEIVE and CRYPTO_DEPOSIT_CREDITED

Both have the same data; confirmed is true once the deposit is credited.

json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "CRYPTO_DEPOSIT_CREDITED",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": { "subAccountId": "12345", "label": "Treasury" },
  "data": {
    "currencySymbol": "BTC",
    "receiveAddress": "bc1qtestaddressexample",
    "transactionHash": "0xabcdef",
    "networkType": "bitcoin",
    "amount": "0.05",
    "sourceCreatedAt": 1700000000000,
    "confirmations": 3,
    "confirmed": true,
    "indexInTx": 0,
    "paymentReference": "invoice-42"
  }
}
SEND_STATUS_UPDATE
json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "SEND_STATUS_UPDATE",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": null,
  "data": {
    "uniqueId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
    "status": "COMPLETE",
    "transactionHash": "0xdeadbeef",
    "confirmations": 6,
    "failureReason": null
  }
}
TRANSFER

fromSubAccountId and toSubAccountId are strings, and null on the primary account's side.

json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "TRANSFER",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": null,
  "data": {
    "id": 42,
    "success": true,
    "failureReason": null,
    "fromIsPrimary": true,
    "fromSubAccountId": null,
    "fromSubAccountLabel": null,
    "toIsPrimary": false,
    "toSubAccountId": "999",
    "toSubAccountLabel": "Treasury",
    "currencySymbol": "USDC",
    "amount": "100.00",
    "description": "Monthly treasury sweep",
    "completedAt": 1700000000000
  }
}
NEW_ACCOUNT_HISTORY_RECORD
json
{
  "id": "11111111-1111-1111-1111-111111111111",
  "type": "NEW_ACCOUNT_HISTORY_RECORD",
  "createdAt": 1700000000000,
  "apiVersion": "2026-09-16",
  "subAccount": null,
  "data": {
    "transactionType": "PAYMENT_RECEIVED",
    "debitCurrencySymbol": null,
    "debitValue": null,
    "creditCurrencySymbol": "ZAR",
    "creditValue": "250.00",
    "feeCurrencySymbol": null,
    "feeValue": null,
    "eventAt": 1700000000000,
    "additionalInfo": null,
    "id": "dddddddd-dddd-dddd-dddd-dddddddddddd"
  }
}

Security checklist ​

  • Verify the signature on every delivery, over the raw body, with a constant-time comparison.
  • Reject deliveries whose timestamp is more than about 5 minutes old.
  • If you restrict inbound traffic, allow 34.39.42.17, and keep verifying signatures.
  • Keep the signing secret out of source control and logs, as you would an API secret.
  • To rotate a secret, create a new webhook with the same URL and event types, update your endpoint to accept the new secret, then delete the old webhook. While both exist, each event arrives twice with the same event ID, each copy signed with its own webhook's secret; skip the repeat by event ID.
  • Treat a webhook as a signal that something changed. Before a high-value action, confirm the details with the API.

Testing your integration ​

  • Your endpoint must be reachable from the internet over HTTPS. While developing, a tunnelling tool can give a local server a public HTTPS URL.
  • Trigger a real event, for example a small transfer between two of your sub-accounts for TRANSFER, and check it with the delivery history.
  • To test your retry handling, have your endpoint return 503 and watch the next attempts arrive with the same event ID.
  • Check your signature code against the example in Verifying signatures before going live.

Endpoints ​

EndpointRequestNeeds
List webhooksGET /v1/webhooksWEBHOOKS_READ
Create a webhookPOST /v1/webhooksWEBHOOKS_MANAGE
Delete a webhookDELETE /v1/webhooks/{id}WEBHOOKS_MANAGE
Get a webhookGET /v1/webhooks/{id}WEBHOOKS_READ
Update a webhookPUT /v1/webhooks/{id}WEBHOOKS_MANAGE
List webhook delivery attemptsGET /v1/webhooks/{id}/deliveriesWEBHOOKS_READ
Get a webhook eventGET /v1/webhooks/{id}/events/{eventId}WEBHOOKS_READ
List webhook event typesGET /v1/webhooks/event-typesWEBHOOKS_READ