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.


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:

  1. Open your utility pipeline in the dashboard
  2. Go to the Request signing section
  3. Reveal the secret

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

EndpointSigning
POST /utilities/sendRequired while signing is enabled on the pipeline (the default)
POST /utilities/end-usersRequired while signing is enabled on the pipeline
POST /utilities/end-users/lookupRequired while signing is enabled on the pipeline
POST /utilities/allowlistAlways required — rejected with SIGNING_REQUIRED if signing is off
POST /utilities/allowlist/checkAlways required
POST /utilities/allowlist/key-fingerprintAlways 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


Errors

StatusCodeCause and Solution
400SIGNING_NOT_CONFIGUREDSigning is enabled but no secret exists yet. Reveal one in the pipeline's Request signing settings.
401MISSING_SIGNATUREOne or more of the three headers is missing.
401SIGNATURE_TIMESTAMP_SKEWTimestamp missing, malformed, or outside ±5 minutes. Send milliseconds and sync your clock (NTP).
401INVALID_SIGNATUREThe computed signature does not match — work through the pitfalls above.
401SIGNATURE_REPLAYThe nonce was already used on this pipeline. Generate a fresh nonce per request.
403SIGNING_REQUIREDAn 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.


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.

Was this page helpful?