Skip to content
SiloraSilora

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

text
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.

typescript
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);
  },
);
Node — Express, with the raw body preserved.
java
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;
    }
}
Java — Spring Boot, taking the body as a String so the bytes survive.

The event catalogue

EventFires whenTerminal
payment.acceptedInstruction validated and acceptedno
payment.screenedSanctions and AML clearedno
payment.routedProvider resolvedno
payment.submittedHanded to the providerno
payment.settledProvider confirmed settlementyes
payment.failedRejected at any stageyes
payment.cancelledCancelled before submissionyes
payment.returnedFunds returned after settlement (pacs.004)yes
beneficiary.validatedConfirmation of Payee completed
quote.expiredA 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.

json
{
  "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. A payment.settled can land before the payment.submitted that preceded it.
  • Retries run with exponential backoff over 24 hours on any non-2xx or 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. 1

    Replay individual deliveries

    List what was attempted with GET /webhooks/endpoints/{id}/deliveries, then re-queue one with POST /webhooks/endpoints/{id}/deliveries/{deliveryId}/replay. Use this after a short outage.

  2. 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. 3

    Run the sweep on a schedule regardless

    A daily reconciliation pass against updatedSince catches anything the webhook channel dropped, and turns a webhook outage into a latency problem rather than a correctness problem.

typescript
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);
}
The sweep. Cursor pagination, not offset — the ledger is append-heavy and offsets drift.