Appearance
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
| Requirement | Description |
|---|---|
| Credentials | Your merchant id (zay-api-key) and private key |
| The failing request | Its method, path, body and the headers you sent |
How it works
1. Read which check failed
Every authentication 401 is a problem-details body; detail says which check failed:
detail | What happened |
|---|---|
Invalid `zay-api-key` header | The merchant id is missing or unknown — check it, and that you're calling the right environment |
Missing `zay-timestamp` or `zay-signature` header | A header wasn't sent |
Request timestamp outside allowed drift of 5 minutes | zay-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 0abash
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 -p3. Fix the usual causes
| Symptom in the comparison | Cause | Fix |
|---|---|---|
Your path has ?… at the end | You signed the query string | Sign the path only, e.g. /merchants/v1/payouts |
| The body differs — key order, spaces, escaping | You signed one serialization and sent another | Serialize the body once, sign that string, and send exactly that string |
Zay's string ends in \n, yours doesn't | A body-less request still needs the newline before the (empty) body | Join all four parts with \n, even when the body is empty |
| The method is lowercase | — | Upper-case it: GET, POST |
| The strings are identical | The private key is wrong, or has a stray space or newline | Copy 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.
Related
- Authentication — the headers and the exact signing rules, with code
- Handle errors and retries