Skip to content
SiloraSilora

Security

Authentication

Every request carries three headers: the key id, a Unix timestamp, and an HMAC-SHA256 signature over the request. The secret is never transmitted — only proof that you hold it.

The three headers

HeaderRequiredValue
X-Silora-KeyyesYour API key id. Not the secret.
X-Silora-TimestampyesUnix seconds. Skew over 300 seconds is rejected with 401.
X-Silora-SignatureyesHex-encoded HMAC-SHA256(secret, timestamp + "." + METHOD + "." + path + "." + sha256(body)).
Idempotency-Keyall POSTA UUID. See Errors for replay semantics.
X-Correlation-IdnoEchoed on the response and threaded through every internal hop.

The canonical string

Four parts, joined by a literal full stop. Nothing else is hashed, and nothing is normalised for you.

text
timestamp . METHOD . path . sha256(body)

1787654328.POST./payments.9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

     timestamp   Unix seconds, the same value sent in X-Silora-Timestamp
        METHOD   Uppercase. GET, POST, PATCH, DELETE.
          path   Version prefix onward, INCLUDING the query string.
                 /payments  ·  /query/payments?status=SETTLED&limit=100
   sha256(body)  Hex SHA-256 of the exact request body bytes.
                 For GET and DELETE, the SHA-256 of the empty string.

Node

typescript
import { createHash, createHmac } from 'node:crypto';

/**
 * Silora request signing.
 *
 *   signature = HMAC-SHA256(secret, timestamp + "." + METHOD + "." + path + "." + sha256(body))
 *
 * Note what goes into the digest: the EXACT bytes you send. Serialise once,
 * sign that string, send that string. Re-serialising between signing and
 * sending is the single commonest cause of a 401 in integration.
 */
export function sign(options: {
  secret: string;
  method: string;
  path: string;      // includes the query string, excludes the host
  body?: string;     // '' for GET and DELETE
  timestamp?: string;
}): { timestamp: string; signature: string } {
  const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000).toString();
  const digest = createHash('sha256').update(options.body ?? '').digest('hex');

  const canonical = [timestamp, options.method.toUpperCase(), options.path, digest].join('.');
  const signature = createHmac('sha256', options.secret).update(canonical).digest('hex');

  return { timestamp, signature };
}
silora-signer.ts — no dependencies beyond node:crypto.
typescript
import { randomUUID } from 'node:crypto';
import { sign } from './silora-signer';

const BASE = 'https://api.sandbox.silorapay.ai/v1';

export async function siloraFetch(
  method: string,
  path: string,
  body?: unknown,
): Promise<Response> {
  const payload = body === undefined ? '' : JSON.stringify(body);

  const { timestamp, signature } = sign({
    secret: process.env.SILORA_KEY_SECRET!,
    method,
    path,
    body: payload,
  });

  const headers: Record<string, string> = {
    'X-Silora-Key': process.env.SILORA_KEY_ID!,
    'X-Silora-Timestamp': timestamp,
    'X-Silora-Signature': signature,
    'X-Correlation-Id': randomUUID(),
  };

  if (payload !== '') {
    headers['Content-Type'] = 'application/vnd.silora.simple+json';
  }
  if (method === 'POST') {
    headers['Idempotency-Key'] = randomUUID();
  }

  return fetch(BASE + path, { method, headers, body: payload === '' ? undefined : payload });
}

// Query strings are signed. Build the path once and reuse it.
const path = '/query/payments?status=SETTLED&limit=100';
const response = await siloraFetch('GET', path);
Using it. Note that the query string is part of the signed path.

Java

java
package ai.silorapay.client;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
 * Silora request signing.
 *
 * signature = HMAC-SHA256(secret, timestamp + "." + METHOD + "." + path + "." + sha256(body))
 */
public final class SiloraSigner {

    private final String secret;

    public SiloraSigner(String secret) {
        this.secret = secret;
    }

    public record Signature(String timestamp, String value) {}

    public Signature sign(String method, String path, String body) {
        String timestamp = Long.toString(Instant.now().getEpochSecond());
        String digest = hex(sha256(body == null ? "" : body));
        String canonical = String.join(".", timestamp, method.toUpperCase(), path, digest);
        return new Signature(timestamp, hex(hmacSha256(secret, canonical)));
    }

    private static byte[] sha256(String input) {
        try {
            return MessageDigest.getInstance("SHA-256")
                .digest(input.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }

    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 String hex(byte[] bytes) {
        StringBuilder out = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            out.append(Character.forDigit((b >> 4) & 0xF, 16));
            out.append(Character.forDigit(b & 0xF, 16));
        }
        return out.toString();
    }
}
SiloraSigner.java — JDK 17 or later, no third-party crypto.
java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

public final class SiloraClient {

    private static final String BASE = "https://api.sandbox.silorapay.ai/v1";

    private final HttpClient http = HttpClient.newHttpClient();
    private final SiloraSigner signer;
    private final String keyId;

    public SiloraClient(String keyId, String keySecret) {
        this.keyId = keyId;
        this.signer = new SiloraSigner(keySecret);
    }

    public HttpResponse<String> post(String path, String jsonBody) throws Exception {
        SiloraSigner.Signature sig = signer.sign("POST", path, jsonBody);

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE + path))
            .header("X-Silora-Key", keyId)
            .header("X-Silora-Timestamp", sig.timestamp())
            .header("X-Silora-Signature", sig.value())
            .header("Idempotency-Key", UUID.randomUUID().toString())
            .header("Content-Type", "application/vnd.silora.simple+json")
            .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();

        return http.send(request, HttpResponse.BodyHandlers.ofString());
    }

    public HttpResponse<String> get(String pathWithQuery) throws Exception {
        SiloraSigner.Signature sig = signer.sign("GET", pathWithQuery, "");

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(BASE + pathWithQuery))
            .header("X-Silora-Key", keyId)
            .header("X-Silora-Timestamp", sig.timestamp())
            .header("X-Silora-Signature", sig.value())
            .GET()
            .build();

        return http.send(request, HttpResponse.BodyHandlers.ofString());
    }
}
Using it with java.net.http.HttpClient.

Clock skew

A timestamp more than 300 seconds from server time is rejected with 401, whatever the signature says. This is a replay defence, not a formality. Run NTP on anything that signs; a container drifting by six minutes produces an outage that looks exactly like a credential failure.

json
401 Unauthorized
Content-Type: application/problem+json

{
  "type": "https://docs.silorapay.ai/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Timestamp skew 412s exceeds the 300s window",
  "correlationId": "01J8KX9P2R7M4Q"
}

Keys, scopes and rotation

  • Keys are issued in the Client Portal, on the API keys screen — not here, and not through the API. The docs site stores no credentials.
  • Sandbox and production keys are separate objects in separate environments. A sandbox key never authenticates against api.silorapay.ai, by design.
  • A key carries scopes. A key without the payments scope gets 403, not 401 — the credential was valid, the permission was not.
  • Rotate by creating the new key, deploying it, then revoking the old one. Both are live during the overlap, so rotation needs no downtime.
  • The secret is shown once at creation. If it is lost, rotate; there is no recovery path, which is the point.