The status channel
Webhooks
The 202 tells you a payment was accepted. Webhooks tell you what happened to it. Configure at least one endpoint before going live, and subscribe to the three terminal events at minimum.
Register an endpoint
POST /v1/webhooks/endpoints
{
"url": "https://treasury.example.com/hooks/silora",
"events": ["payment.settled", "payment.failed", "payment.returned"],
"description": "Treasury settlement feed"
}Delivery headers
X-Silora-Event: payment.settled
X-Silora-Uetr: 7a9c1e02-4f3b-4c8e-9d21-6b0f5a8e33c1
X-Silora-Delivery: dlv_01J8KX9P2R
X-Silora-Timestamp: 1787654328
X-Silora-Signature: sha256=<hex>
signature = HMAC-SHA256(endpoint secret, timestamp + "." + raw body)Verify every delivery
Verify against the raw request body, before any JSON parsing. A body that has been parsed and re-serialised will not verify. Compare in constant time, and reject a timestamp more than 300 seconds old.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SKEW_LIMIT_SECONDS = 300;
export function verifySiloraWebhook(
secret: string,
rawBody: Buffer,
timestampHeader: string,
signatureHeader: string,
): boolean {
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestampHeader));
if (!Number.isFinite(skew) || skew > SKEW_LIMIT_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(timestampHeader + '.' + rawBody.toString('utf8'))
.digest('hex');
const received = signatureHeader.replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
// express.json() would consume the stream. Keep the raw bytes.
app.post(
'/hooks/silora',
express.raw({ type: '*/*' }),
async (request, response) => {
const ok = verifySiloraWebhook(
process.env.SILORA_WEBHOOK_SECRET!,
request.body as Buffer,
String(request.header('X-Silora-Timestamp')),
String(request.header('X-Silora-Signature')),
);
if (!ok) return response.status(401).end();
const event = JSON.parse((request.body as Buffer).toString('utf8'));
// Acknowledge inside 5 seconds; do the work afterwards.
response.status(204).end();
await enqueue(event);
},
);package ai.silorapay.webhooks;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class SiloraWebhookController {
private static final long SKEW_LIMIT_SECONDS = 300L;
private final String secret;
private final DeliveryQueue queue;
public SiloraWebhookController(String secret, DeliveryQueue queue) {
this.secret = secret;
this.queue = queue;
}
@PostMapping(path = "/hooks/silora", consumes = "*/*")
public ResponseEntity<Void> receive(
@RequestBody String rawBody,
@RequestHeader("X-Silora-Timestamp") String timestamp,
@RequestHeader("X-Silora-Signature") String signature,
@RequestHeader("X-Silora-Delivery") String deliveryId) {
if (!verify(rawBody, timestamp, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// At-least-once delivery: dedupe on eventId, not on deliveryId.
queue.enqueue(rawBody);
// Acknowledge within 5 seconds; process asynchronously.
return ResponseEntity.noContent().build();
}
private boolean verify(String rawBody, String timestamp, String signature) {
long skew = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp));
if (skew > SKEW_LIMIT_SECONDS) {
return false;
}
byte[] expected = hmacSha256(secret, timestamp + "." + rawBody);
byte[] received = fromHex(signature.replaceFirst("^sha256=", ""));
// Constant time. String.equals leaks the comparison length.
return MessageDigest.isEqual(expected, received);
}
private static byte[] hmacSha256(String key, String message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("HMAC-SHA256 failed", e);
}
}
private static byte[] fromHex(String hex) {
byte[] out = new byte[hex.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}The event catalogue
| Event | Fires when | Terminal |
|---|---|---|
payment.accepted | Instruction validated and accepted | no |
payment.screened | Sanctions and AML cleared | no |
payment.routed | Provider resolved | no |
payment.submitted | Handed to the provider | no |
payment.settled | Provider confirmed settlement | yes |
payment.failed | Rejected at any stage | yes |
payment.cancelled | Cancelled before submission | yes |
payment.returned | Funds returned after settlement (pacs.004) | yes |
beneficiary.validated | Confirmation of Payee completed | — |
quote.expired | A held rate lapsed unused | — |
Subscribe to the terminal three at minimum. The intermediate events are useful for a live tracking UI and unnecessary for reconciliation.
Returns are not failures
A payment.returned is funds coming back after settlement — a distinct outcome your ledger must handle separately, because the money left and came back, minus charges.
{
"event": "payment.returned",
"uetr": "7a9c1e02-4f3b-4c8e-9d21-6b0f5a8e33c1",
"status": "RETURNED",
"isoStatus": "RJCT",
"isoReasonCode": "AC04",
"reasonText": "Beneficiary account closed",
"terminal": true,
"returnedAmount": { "currency": "USD", "value": "24480.00" },
"chargesDeducted": { "currency": "USD", "value": "20.00" },
"returnedAt": "2026-08-23T11:04:00Z"
}Delivery guarantees
- At-least-once. Deduplicate on
eventId. The same event will arrive twice eventually; design for it rather than hoping. - Order is not guaranteed. Trust
occurredAt, never arrival order. Apayment.settledcan land before thepayment.submittedthat preceded it. - Retries run with exponential backoff over 24 hours on any non-
2xxor timeout. - Respond `2xx` within 5 seconds. Acknowledge, enqueue, and do the work asynchronously. A slow handler becomes a retry storm.
- Your `metadata` is echoed on every webhook, so reconciliation needs no second lookup.
- An endpoint failing for 24 hours is suspended and you are notified.
The recovery path
Webhooks are the fast path, not the only path. Never build a system that cannot recover without them.
- 1
Replay individual deliveries
List what was attempted with
GET /webhooks/endpoints/{id}/deliveries, then re-queue one withPOST /webhooks/endpoints/{id}/deliveries/{deliveryId}/replay. Use this after a short outage. - 2
Reconcile from the query endpoint
If your endpoint was down longer than the 24-hour retry window, the deliveries are gone. Sweep instead:
GET /query/payments?updatedSince=2026-08-22T00:00:00Z&limit=500, page by cursor, and apply terminal states from the result. - 3
Run the sweep on a schedule regardless
A daily reconciliation pass against
updatedSincecatches anything the webhook channel dropped, and turns a webhook outage into a latency problem rather than a correctness problem.
export async function reconcileSince(iso: string) {
let cursor: string | undefined;
do {
const query = new URLSearchParams({ updatedSince: iso, limit: '500' });
if (cursor) query.set('cursor', cursor);
const response = await siloraFetch('GET', '/query/payments?' + query.toString());
const page = await response.json();
for (const payment of page.data) {
await applyTerminalState(payment); // idempotent on uetr
}
cursor = page.page.hasMore ? page.page.cursor : undefined;
} while (cursor);
}