π¨ Receiving notifications
The management endpoints only describe how a subscription is created. This page describes the other half: what the platform actually sends to webhook_url, when it sends it, and what a receiver has to handle.
The request your endpoint receives
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Settla-Webhook/1.0
X-Settla-Timestamp: 1757419541
X-Settla-Signature: v1=6f3a0e1dβ¦
The two X-Settla-* headers let you prove the request came from us β see Verifying that a request came from us.
{
"type": "new_transaction",
"address": "TLWKepDBC8RZjAhtWyNqASyzgyVyHUuS7o",
"transaction": {
"txID": "aa451147fe9bc4514ebc6adc58582eabd480c2869a649571a9262d2e95dae9cd",
"from": "TJbUDLSLdiuh1TdZ5hUEKMuboJ3X41vwxG",
"to": "TLWKepDBC8RZjAhtWyNqASyzgyVyHUuS7o",
"value": "7975000000",
"currency": "USDT",
"block_number": 75784535,
"timestamp": "2025-09-16T09:05:30.000000Z"
},
"timestamp": "2025-09-16T09:05:41.000000Z"
}
π οΈ Properties
| Name | Type | Description |
|---|---|---|
| type | string | Event type. new_transaction is the only value the platform currently sends. |
| address | string | The address from your subscription that this transaction touched. transaction.to is checked first and transaction.from second, so if both sides of the transfer are yours you get the recipient. |
| transaction.txID | string | Transaction hash. Tron and Bitcoin use the txid as-is, Ethereum uses the hash field of the transaction. |
| transaction.from | string | Sender address. |
| transaction.to | string | Recipient address. See the per-coin table below: for token transfers this is not always the address you expect. |
| transaction.value | string | Amount in the minimal units of currency, decimal string. Never a number β amounts exceed the range of a IEEE-754 double. |
| transaction.currency | string | Asset symbol, see below. Falls back to the contract address when the token is not in the platform's list. |
| transaction.block_number | integer | Height of the block the transaction was included in. |
| transaction.timestamp | string | Block time, ISO 8601. |
| timestamp | string | When the notification was generated, ISO 8601. Differs from the block time by the scanner lag. |
There is no confirmations field, no signature header and no unique event id. All three are covered in their own sections below.
Amounts and the currency field
| Coin | currency | value is in | Decimals |
|---|---|---|---|
| TRX, native transfer | TRX | SUN | 6 |
| TRX, TRC-20 transfer | Token symbol (USDT) or, for tokens outside the platform list, the contract address in Base58 | Minimal token units | Per token; USDT on Tron has 6 |
| TRX, TRC-10 transfer | The asset name as returned by the node (hex-encoded token id) | Minimal token units | Per token |
| BTC | BTC | Satoshi | 8 |
| ETH | ETH | Wei | 18 |
Which transactions match your addresses
Addresses are matched as exact strings against the sender and the recipient of every transfer in a scanned block. There is no normalisation, so the representation matters:
- ETH β the scanner lower-cases addresses before matching. Store your addresses in lower case; a checksummed
0xAbCβ¦address never matches. - TRX β Base58 (
Tβ¦). Hex (41β¦) addresses are converted by the scanner, so subscribe with the Base58 form. - BTC β the address as it appears in
scriptPubKey, i.e. bech32 in lower case forbc1β¦outputs.
What triggers a notification per coin:
| Chain | Incoming | Outgoing |
|---|---|---|
| TRX, native | β | β |
| TRX, TRC-20 (USDT included) | β
β the recipient is decoded from the contract call, value is the token amount | β |
| TRX, TRC-10 | β | β |
| BTC | β β one notification per matching output | β οΈ only if your address is the first input of the transaction |
| ETH, native | β | β |
| ETH, ERC-20 (USDT, USDC) | β not delivered β see below | β οΈ delivered, but to is the token contract and value is "0" |
Notes:
- Tron contract calls. Only the standard
transfer(address,uint256)call (and one proxy variant) is decoded. Any other contract call made by one of your addresses is still reported, but with the contract astoand"0"asvalueβ check for a non-zerovaluebefore treating a notification as a payment. - Bitcoin. A transaction is reported per output: one notification for each output paying one of your addresses, with
valuebeing that output's amount. Outputs paying back an input address (change) are skipped. On the sending side only the first input address is reported asfrom, so an outgoing payment whose first input is not yours produces no notification. - Ethereum tokens. For an ERC-20 transfer the transaction's recipient is the token contract, not the beneficiary, and matching runs on that field. As a result an incoming USDT/USDC payment on Ethereum does not trigger a webhook. If you need USDT notifications today, use USDT on Tron (
coin=TRX), where token transfers are decoded in full. Support for ERC-20 recipients is a known gap and is being worked on.
Addresses start being watched from the next block the scanner processes. There is no backfill: transactions that happened before you created or updated the subscription are not replayed. To recover the past on Tron use GET /api/v1/trx/address/{address}/transactions; on Bitcoin and Ethereum read the blocks you need through the block endpoints.
Confirmations and reorgs
The notification is sent as soon as the block containing the transaction is processed, which is normally a few seconds after the block is produced. In practice this means one confirmation. The payload carries no confirmations field β track depth yourself by comparing transaction.block_number with the current height from GET /api/v1/{trx,btc,eth}/block/last, and release goods only at the depth your risk model requires.
Consequences to design for:
- A chain reorganisation is not reported. There is no cancellation or
transaction_failedevent; a notification for a transaction that later drops out of the chain is never retracted. - On Ethereum the scanner reads block bodies without receipts, so a reverted transaction is reported like a successful one. Check the status with
GET /api/v1/eth/transactions/{hash}/receiptbefore crediting an ETH payment. On Tron only transactions withcontractRet: SUCCESSare reported, and Bitcoin has no equivalent case.
Delivery, retries and the circuit breaker
| Property | Value |
|---|---|
| Method | POST, body application/json |
| Success | Any 2xx response |
| Timeout | 15 seconds |
| Redirects | Not followed β a 3xx counts as a failure |
| Retries | 5 attempts total, with 10 s, 30 s, 60 s and 300 s between them |
| After the last attempt | The delivery is counted as failed |
Every attempt β successful or not β is recorded and visible in the dashboard under Webhook statistics, including the HTTP status, the response time and the response body of successful deliveries.
After 20 consecutively failed deliveries the subscription is switched off: is_active becomes 0, the owner gets an email, and nothing is delivered until you re-enable it with a PUT request. A single failed attempt does not count β only a delivery that has exhausted all its retries. One successful delivery resets the counter.
A subscription is also switched off immediately, without retries, if its URL stops being publicly reachable β for example when the host starts resolving into a private or loopback range. The reason is shown in the dashboard.
Duplicates and idempotency
The same transaction may be delivered more than once, and the payload carries no unique event id. Make the receiver idempotent: key your processing on transaction.txID + address (add transaction.block_number if you want to observe re-deliveries), and treat a repeat as a no-op rather than a second payment.
Respond 200 as soon as you have stored the event and do the heavy work asynchronously β the 15-second timeout applies to your whole response.
Verifying that a request came from us
Every delivery is signed with an HMAC-SHA256 keyed on a secret that belongs to that one subscription. Two headers carry it:
| Header | Example |
|---|---|
X-Settla-Signature | v1=6f3aβ¦c1 β the scheme version and the signature in lowercase hex |
X-Settla-Timestamp | 1757419541 β Unix seconds, when the request was signed |
What is signed is not the body alone. The signed string is the timestamp, a literal dot, and the raw request body:
<X-Settla-Timestamp> "." <raw body bytes>
The timestamp is inside the signature on purpose: signing only the body would let anyone replay a delivery captured months ago with a fresh-looking header.
Getting the secret
The secret is created together with the subscription and comes back in the secret field of every webhook object β POST /api/v1/webhooks, GET /api/v1/webhooks, GET /api/v1/webhooks/{id} and PUT /api/v1/webhooks/{id}. It looks like whsec_ followed by 64 hex characters, and the whole string, prefix included, is the HMAC key.
Subscriptions created before signing was introduced were given a secret automatically β you do not have to recreate them, just read it off the list:
curl -sS https://settla.net/api/v1/webhooks \
-H "X-API-KEY: $SETTLA_API_KEY"
{
"data": [
{
"id": 1387,
"coin": "trx",
"webhook_url": "https://example.com/hooks/settla",
"secret": "whsec_4f8c1a9d7e2b6035c4a1f8d9e0b3c7a25f6e1d4c8b0a9372e5d6c1f804b3a7e29",
"addresses": ["TLWKepDBC8RZjAhtWyNqASyzgyVyHUuS7o"],
"is_active": 1
}
]
}
The value is stable β it is the same key on every read, so there is no one-time-display step to miss, unlike an API key. It is only ever returned to the owner of the subscription, authenticated with that owner's API key.
To roll the key over:
curl -sS -X POST https://settla.net/api/v1/webhooks/1387/secret \
-H "X-API-KEY: $SETTLA_API_KEY"
The response is the webhook object with the new secret. The old one stops working immediately β there is no overlap window, so deploy the new value to your receiver before rotating, or accept a short gap.
Checking the signature
Verify against the raw request body, before any JSON parsing: re-serialising the payload changes the bytes and the signature will not match.
// PHP
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SETTLA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SETTLA_SIGNATURE'] ?? '';
// ΠΠ΅ ΠΏΡΡΠΊΠ°Π΅ΠΌ ΠΏΡΠΎΡΡΡ
ΡΠΈΠ΅ Π΄ΠΎΡΡΠ°Π²ΠΊΠΈ: ΠΏΠΎΠ΄ΠΏΠΈΡΡ Π±Π΅Π· ΠΎΠΊΠ½Π° ΡΠ²Π΅ΠΆΠ΅ΡΡΠΈ Π·Π°ΡΠΈΡΠ°Π΅Ρ ΠΎΡ
// ΠΏΠΎΠ΄Π΄Π΅Π»ΠΊΠΈ, Π½ΠΎ Π½Π΅ ΠΎΡ ΠΏΠΎΠ²ΡΠΎΡΠ° ΠΊΠΎΠ³Π΄Π°-ΡΠΎ ΠΏΠ΅ΡΠ΅Ρ
Π²Π°ΡΠ΅Π½Π½ΠΎΠ³ΠΎ Π·Π°ΠΏΡΠΎΡΠ°.
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
// Node.js (Express: express.raw({ type: 'application/json' }))
const crypto = require('crypto');
function verify(req, secret) {
const timestamp = req.get('X-Settla-Timestamp') || '';
const signature = req.get('X-Settla-Signature') || '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
'v1=' +
crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + req.body) // req.body is a Buffer, not an object
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Compare in constant time (hash_equals, timingSafeEqual) rather than with ===, and reject anything whose X-Settla-Signature does not start with v1= β a future scheme will use a different prefix, and silently accepting it would defeat the point.
The dashboard's Test button signs its delivery with the same key, so you can check your verification code without waiting for a real transaction.
Still worth doing
- Keep the
webhook_urlunguessable β a random path segment over HTTPS. - Treat the payload as a hint, not as truth: re-read the transaction with
GET /api/v1/{trx,btc,eth}/transactions/{hash}before crediting anything. A valid signature proves the message came from us, not that the deposit is still confirmed after a reorg.
The βTestβ button in the dashboard
The test delivery in the dashboard sends a different, synthetic payload, kept for backwards compatibility with the original integration:
{
"event": "transaction_confirmed",
"coin": "TRX",
"transaction_id": "test_5f2c9a7d3b1",
"from_address": "test_sender_address",
"to_address": "TLWKepDBC8RZjAhtWyNqASyzgyVyHUuS7o",
"amount": "1000000",
"confirmations": 6,
"block_height": 12345678,
"timestamp": "2025-09-16T12:00:00.000000Z",
"test": true
}
Use it to check reachability and TLS, not to validate your parser: a payload with "test": true never has the shape of a real notification. Build against the new_transaction format documented above and ignore or explicitly skip anything carrying test: true.
How many addresses can one webhook hold?
addresses must contain at least one entry, and each address is limited to 255 characters. There is no enforced upper bound on the number of entries, but matching is linear in the size of the list, so keep a subscription under about 1000 addresses and split larger pools across several subscriptions. Nothing prevents several subscriptions of the same coin from pointing at the same URL.
PUT replaces the whole list rather than merging: send the complete set of addresses on every update, including the ones that are not changing.
Watching addresses you generated yourself
A subscription needs nothing but the address β the platform never asks for a private key, a mnemonic or an xpub in order to watch it. Addresses generated in your own wallet are watched exactly like addresses generated through the API.
This is the recommended replacement for the deprecated key-handling endpoints on Tron (POST /api/v1/trx/account, POST /api/v1/trx/account/privatekey): derive the address in your wallet at the standard Tron path m/44'/195'/0'/0/i, subscribe it with POST /api/v1/webhooks, and sign outgoing transfers on your side with POST /api/v1/trx/transactions/create + POST /api/v1/trx/transactions/broadcast.