Request Signing
Utilities API calls from your backend are HMAC-SHA256 signed with a per-pipeline secret — on by default for every pipeline, and required for the allowlist and end-user endpoints even when a pipeline turns signing off. The signature proves the request comes from you — not from anyone who obtained your API key — and makes replayed or tampered requests fail.
This is the opposite direction of webhook signing
This page covers signing requests you send to Akedly. For verifying webhooks Akedly sends to you, see Webhook Signing — a different scheme with different headers and a different secret.
Why requests are signed
An API key travels in every request body, sits in environment files, and appears in logs — it is an identifier with weak secrecy. Utility sends cost money and reach real phones, so the utilities API adds a second factor: each request is signed with a secret that never leaves your backend.
The signature covers the exact request body plus a timestamp and a single-use nonce. A captured request cannot be replayed (the nonce is spent), an old request cannot be re-sent (the timestamp expires), and a modified body fails verification.
Where to find your secret
Each utility pipeline has its own request-signing secret, prefixed with usk_. To retrieve it:
- Open your utility pipeline in the dashboard
- Go to the Request signing section
- Click Reveal secret
View-once secret
The secret is shown one time; copy it into your secret manager immediately, and regenerate it if lost. Signing uses usk_… exactly as displayed.
Example secret
usk_kU3+tF2dG8XzL9pQrSvT4Bh6jYwMnRb1cZxK7eNoAi0=
Unlike webhook secrets, the signing secret is used as-is as the HMAC key — no prefix stripping, no base64 decoding.
The signing headers
Every signed request carries three headers next to Content-Type: application/json:
- Name
x-akedly-timestamp- Type
- string
- Description
Unix time in milliseconds when you built the request. Accepted within ±5 minutes of Akedly's clock.
- Name
x-akedly-nonce- Type
- string
- Description
A random value unique to this request — a UUID or 16 random hex bytes. A nonce is single-use per pipeline for 10 minutes (longer than the timestamp window, so a captured request can never be replayed); a reused nonce is rejected.
- Name
x-akedly-signature- Type
- string
- Description
Hex-encoded HMAC-SHA256 signature computed as described below.
Example headers
x-akedly-timestamp: 1784642400000
x-akedly-nonce: 9f0a4c6e2b8d47c1a3e5f7091b2d4c6f
x-akedly-signature: 3f6b1c…64 hex chars…9a2e
Signing algorithm
Algorithm
1. Serialize the body ONCE and keep the exact bytes:
body = '{"APIKey":"…","pipelineID":"…",…}'
2. Hash the body:
bodyHash = hex( SHA256(body) )
3. Build the canonical string:
canonical = "${timestamp}.${nonce}.${bodyHash}"
4. Sign it with your signing secret (used as-is):
signature = hex( HMAC_SHA256(secret, canonical) )
5. Send the three headers with the SAME body bytes from step 1.
Akedly recomputes the signature over the raw bytes it received. If you re-serialize the body after signing — pretty-printing, key re-ordering, a framework re-encoding it — the bytes change and verification fails.
Sign in your stack
A small helper that returns ready-to-send headers for any body. The same helper works for every signed utilities endpoint.
Build signed headers
const crypto = require("crypto");
function signedHeaders(body) {
const timestamp = Date.now().toString();
const nonce = crypto.randomBytes(16).toString("hex");
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const signature = crypto
.createHmac("sha256", process.env.AKEDLY_SIGNING_SECRET)
.update(`${timestamp}.${nonce}.${bodyHash}`)
.digest("hex");
return {
"Content-Type": "application/json",
"x-akedly-timestamp": timestamp,
"x-akedly-nonce": nonce,
"x-akedly-signature": signature,
};
}
// usage — sign the exact string you send
async function sendUtilityMessage() {
const body = JSON.stringify({
APIKey: process.env.AKEDLY_API_KEY,
pipelineID: "<YOUR_PIPELINE_ID>",
templateId: "<TEMPLATE_ID>",
variableValues: { orderNumber: "1042" },
phone: "+201234567890",
customerUserId: "user_123",
});
const res = await fetch(
"https://api.akedly.io/api/v1/utilities/send",
{ method: "POST", headers: signedHeaders(body), body }
);
return res.json();
}
Which endpoints require signing
| Endpoint | Signing |
|---|---|
POST /utilities/send | Required while signing is enabled on the pipeline (the default) |
POST /utilities/end-users | Always required — rejected with SIGNING_REQUIRED if signing is off |
POST /utilities/end-users/lookup | Always required — rejected with SIGNING_REQUIRED if signing is off |
Signing is on by default for every utility pipeline. On a pipeline with signing disabled the requirement drops for /send only. The end-user endpoints (/end-users, /end-users/lookup) refuse to run unsigned and answer SIGNING_REQUIRED regardless of the pipeline's setting — signing is their anti-enumeration control, so it cannot be turned off. Keep signing on; it is the intended posture for every integration.
Common pitfalls
Four things that catch most integrators
1. Re-serializing the body after signing. Sign the exact string you send. In Python use data=body, not json=…; in Node pass the same body string to fetch you hashed.
2. Seconds instead of milliseconds. x-akedly-timestamp is Unix time in milliseconds. A seconds value looks ~55 years stale and fails the skew check.
3. Reusing a nonce. Every request needs a fresh nonce — including retries of a failed request. Only an idempotent retry reuses the Idempotency-Key, never the nonce.
4. Decoding the secret. usk_… is the HMAC key exactly as displayed. Do not strip the prefix or base64-decode it (webhook secrets work differently).
Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | SIGNING_NOT_CONFIGURED | Signing is enabled but no secret exists yet. Reveal one in the pipeline's Request signing settings. |
| 401 | MISSING_SIGNATURE | One or more of the three headers is missing. |
| 401 | SIGNATURE_TIMESTAMP_SKEW | Timestamp missing, malformed, or outside ±5 minutes. Send milliseconds and sync your clock (NTP). |
| 401 | INVALID_SIGNATURE | The computed signature does not match — work through the pitfalls above. |
| 401 | SIGNATURE_REPLAY | The nonce was already used on this pipeline. Generate a fresh nonce per request. |
| 403 | SIGNING_REQUIRED | A fail-closed endpoint — /end-users or /end-users/lookup — was called while signing is disabled (or no signing secret is set) on the pipeline. These do not honour the pipeline's signing toggle. Enable signing, then sign the request. |
Rotating the secret
Regenerate the signing secret from the pipeline's Request signing settings if it leaks or on a routine schedule.
Plan for the cutover
There is no overlap window: the moment you regenerate, requests signed with the old secret are rejected with INVALID_SIGNATURE. Update your environment and redeploy before the next send, or briefly pause sends around the rotation.
