Appearance
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 ​
- Build an endpoint that accepts
POSTrequests over HTTPS and returns a2xxstatus within 3 seconds. See Receiving deliveries. - Create a webhook with Create a webhook, giving your URL and the event types you want. List webhook event types shows what's available.
- Store the signing secret from the response. It's shown only once.
- Verify the signature on every delivery before you trust it. See Verifying signatures.
- 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 ​
| Limit | Value |
|---|---|
| Webhooks per business | 20, counting paused webhooks and those created by your team in VALR Business |
| Event types per webhook | 1 to 20 |
| URL length | 2,048 characters |
| Description | Up to 256 characters of plain text |
| Delivery timeout | 3 seconds |
| Delivery attempts | Up to 10, over about 46 hours. See Retries. |
| Delivery history page size | Up 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 ​
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | VALR-Webhooks/1.0 |
Valr-Webhook-Event-Id | The event's ID. The same on every retry, and on every webhook that receives the event. |
Valr-Webhook-Event-Type | The event type, for example FIAT_DEPOSIT_CREDITED |
Valr-Webhook-Delivery-Attempt | 1 for the first attempt, then 2, 3 and so on |
Valr-Webhook-Timestamp | When this attempt was signed, in Unix milliseconds |
Valr-Webhook-Signature | v1= 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": { }
}| Field | Description |
|---|---|
id | The event's ID, as in Valr-Webhook-Event-Id |
type | The event type, as in Valr-Webhook-Event-Type |
createdAt | When the event was created, in Unix milliseconds |
apiVersion | The payload version, currently 2026-09-16 |
subAccount | The sub-account the event happened on, with its subAccountId (a string) and label; null for your primary account |
data | The 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
nullrather 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:
- Read the
Valr-Webhook-TimestampandValr-Webhook-Signatureheaders. - 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. - 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.
- Compare your result with the value after
v1=inValr-Webhook-Signature, using a constant-time comparison. Ignore any other versions in the header. - 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
2xxstatus. 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
3xxresponse 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
dataand, 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 response | What happens |
|---|---|
2xx | Delivered. Nothing more is sent for this event. |
408, 429 or any 5xx | Retried |
| No response within 3 seconds, connection refused or reset, DNS or TLS failure | Retried |
Any other 4xx, or any 3xx | Permanent failure, not retried |
Each retry waits longer after the previous attempt:
| Attempt | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|
| Delay | 30 s | 1 min | 5 min | 15 min | 1 h | 3 h | 6 h | 12 h | 24 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:
| Category | Meaning |
|---|---|
HTTP_STATUS | Your endpoint returned a status other than 2xx; see responseStatus |
TIMEOUT | Your endpoint didn't respond within 3 seconds |
CONNECTION_REFUSED | Your endpoint refused the connection |
CONNECTION_RESET | Your endpoint closed the connection unexpectedly |
DNS_FAILURE | The host name couldn't be resolved |
TLS_FAILURE | The TLS handshake failed. Check that your certificate is valid and hasn't expired. |
NETWORK_UNREACHABLE | Your endpoint's host couldn't be reached |
BLOCKED | The URL failed our safety checks when sending, for example because its host now resolves to a private address |
UNKNOWN | Something else went wrong. Contact support if it keeps happening. |
Event types ​
List webhook event types returns the current list.
| Event type | Sent when |
|---|---|
FIAT_DEPOSIT_CREDITED | A bank deposit is credited. reference is the deposit reference it matched, or null. |
FIAT_WITHDRAWAL_STATUS_UPDATE | A fiat withdrawal changes status |
NEW_PENDING_RECEIVE | A crypto deposit is detected on one of your deposit addresses |
CRYPTO_DEPOSIT_CREDITED | A crypto deposit is confirmed and credited |
SEND_STATUS_UPDATE | A crypto withdrawal completes or fails |
TRANSFER | Funds move between your primary account and a sub-account, or between sub-accounts. Sent for the account the funds left. |
NEW_ACCOUNT_HISTORY_RECORD | Any 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
503and watch the next attempts arrive with the same event ID. - Check your signature code against the example in Verifying signatures before going live.
Endpoints ​
| Endpoint | Request | Needs |
|---|---|---|
| List webhooks | GET /v1/webhooks | WEBHOOKS_READ |
| Create a webhook | POST /v1/webhooks | WEBHOOKS_MANAGE |
| Delete a webhook | DELETE /v1/webhooks/{id} | WEBHOOKS_MANAGE |
| Get a webhook | GET /v1/webhooks/{id} | WEBHOOKS_READ |
| Update a webhook | PUT /v1/webhooks/{id} | WEBHOOKS_MANAGE |
| List webhook delivery attempts | GET /v1/webhooks/{id}/deliveries | WEBHOOKS_READ |
| Get a webhook event | GET /v1/webhooks/{id}/events/{eventId} | WEBHOOKS_READ |
| List webhook event types | GET /v1/webhooks/event-types | WEBHOOKS_READ |