Request Signing
Every utilities API call from your backend is HMAC-SHA256 signed with a per-pipeline secret. 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
- Reveal the secret
View-once — and it's not the hash secret
The secret is shown one time; copy it into your secret manager immediately, and regenerate it if lost. Each pipeline also has a separate hash secret (uhs_…) used only for allowlist hashing — signing with the hash secret is the most common mix-up. Signing uses usk_….
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 checkEligibility() {
const body = JSON.stringify({
APIKey: process.env.AKEDLY_API_KEY,
pipelineID: "<YOUR_PIPELINE_ID>",
phone: "+201234567890",
});
const res = await fetch(
"https://api.akedly.io/api/v1/utilities/allowlist/check",
{ 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 | Required while signing is enabled on the pipeline |
POST /utilities/end-users/lookup | Required while signing is enabled on the pipeline |
POST /utilities/allowlist | Always required — rejected with SIGNING_REQUIRED if signing is off |
POST /utilities/allowlist/check | Always required |
POST /utilities/allowlist/key-fingerprint | Always required |
Signing is on by default for every utility pipeline. On a pipeline with signing disabled, the requirement drops for /send and the end-user endpoints — but the allowlist endpoints refuse to run unsigned. Keep signing on; it is the intended posture for every integration.
Common pitfalls
Five 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).
5. Signing with the hash secret. uhs_… hashes allowlist numbers; usk_… signs requests. Mixing them up produces a well-formed but invalid signature.
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 | An allowlist endpoint was called while signing is disabled (or no signing secret is set) on the pipeline. 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.
Next: recipient eligibility
Signing in hand, set up who you're allowed to message. The Allowlist endpoints require this same signature (fail-closed), and every send is signed the same way.