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
| Header | Required | Value |
|---|---|---|
X-Silora-Key | yes | Your API key id. Not the secret. |
X-Silora-Timestamp | yes | Unix seconds. Skew over 300 seconds is rejected with 401. |
X-Silora-Signature | yes | Hex-encoded HMAC-SHA256(secret, timestamp + "." + METHOD + "." + path + "." + sha256(body)). |
Idempotency-Key | all POST | A UUID. See Errors for replay semantics. |
X-Correlation-Id | no | Echoed 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 };
}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);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();
}
}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());
}
}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, not401— 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.