Skip to content

Sign requests ​

Find out why Zay rejected a request with 401, and fix your signing code.

The exact signing rules are in Authentication. A request that already works doesn't need this page.

Requirements ​

RequirementDescription
CredentialsYour merchant id (zay-api-key) and private key
The failing requestIts method, path, body and the headers you sent

How it works ​

  1. Read which check failed
  2. Compare what Zay signed with what you signed
  3. Fix the usual causes

1. Read which check failed ​

Every authentication 401 is a problem-details body; detail says which check failed:

detailWhat happened
Invalid `zay-api-key` headerThe merchant id is missing or unknown — check it, and that you're calling the right environment
Missing `zay-timestamp` or `zay-signature` headerA header wasn't sent
Request timestamp outside allowed drift of 5 minuteszay-timestamp is more than ±5 minutes from Zay's clock
The request signature we calculated does not match the signature you provided. …Both headers arrived, but the HMAC differs

For the first three, fix the header, the environment or your clock (sync it with NTP) and you're done. For a mismatch, go on.

2. Compare what Zay signed with what you signed ​

A mismatch response carries string_to_sign_bytes — the exact bytes Zay signed:

json
{
  "type": "about:blank",
  "title": "Invalid request signature",
  "status": 401,
  "detail": "The request signature we calculated does not match the signature you provided. Check your secret key and signing method.",
  "instance": null,
  "errors": {},
  "string_to_sign_bytes": [49, 55, 51, 51, 49, 56, 50, 52, 48, 48, 10, 71, 69, 84, 10, 47, 109, 101, 114, 99, 104, 97, 110, 116, 115, 47, 118, 49, 47, 112, 97, 121, 111, 117, 116, 115, 10]
}

Turn them back into a string and compare it, byte for byte, with the payload your code signed:

python
# Python
bytes(body["string_to_sign_bytes"]).decode("utf-8")
# → "1733182400\nGET\n/merchants/v1/payouts\n"
javascript
// Node.js
Buffer.from(body.string_to_sign_bytes).toString("utf8");
// → "1733182400\nGET\n/merchants/v1/payouts\n"
ruby
# Ruby
body["string_to_sign_bytes"].pack("C*")
# => "1733182400\nGET\n/merchants/v1/payouts\n"

If the two strings are identical, the bytes are right and the key is wrong — see the last row below.

Bytes rather than a string, because the bugs this is for are invisible in a string: a non-breaking space, a byte-order mark, a \r before \n, trailing whitespace.

Prefer plain text? ​

Send Accept: text/plain and the response is plain text, with the bytes as space-separated lowercase hex:

The request signature we calculated does not match the signature you provided. Check your secret key and signing method.

StringToSignBytes (hex, space-separated, lowercase):
31 37 33 33 31 38 32 34 30 30 0a 47 45 54 0a 2f 6d 65 72 63 68 61 6e 74 73 2f 76 31 2f 70 61 79 6f 75 74 73 0a
bash
echo "31 37 33 33 31 38 32 34 30 30 0a 47 45 54 0a 2f 6d 65 72 63 68 61 6e 74 73 2f 76 31 2f 70 61 79 6f 75 74 73 0a" \
  | tr -d ' ' | xxd -r -p

3. Fix the usual causes ​

Symptom in the comparisonCauseFix
Your path has ?… at the endYou signed the query stringSign the path only, e.g. /merchants/v1/payouts
The body differs — key order, spaces, escapingYou signed one serialization and sent anotherSerialize the body once, sign that string, and send exactly that string
Zay's string ends in \n, yours doesn'tA body-less request still needs the newline before the (empty) bodyJoin all four parts with \n, even when the body is empty
The method is lowercase—Upper-case it: GET, POST
The strings are identicalThe private key is wrong, or has a stray space or newlineCopy the key again, with nothing around it

Check the result ​

Send the request again, re-signed with a fresh timestamp. A 2xx means the signature is right.