> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zapyd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Sign every request with HMAC-SHA256, with helpers in three languages and a test vector.

Every API request carries three headers. Zapyd recomputes the signature on its side and rejects the request if it does not match.

<Note>
  Get your **API Key** and **API Secret** from our [support team](mailto:support@zapyd.com). Sandbox and production credentials are separate and not interchangeable.
</Note>

## Required headers

<ResponseField name="X-API-KEY" type="string (UUID)" required>
  Your API key. Identifies your organization.
</ResponseField>

<ResponseField name="X-TIMESTAMP" type="integer" required>
  Current Unix time in **seconds**. Requests more than **300 seconds** away from server time are rejected, so keep your server clock in sync (NTP).
</ResponseField>

<ResponseField name="X-SIGNATURE" type="string" required>
  Base64-encoded HMAC-SHA256 of the signing string, keyed with your API secret.
</ResponseField>

## Signature algorithm

```text theme={"theme":{"light":"css-variables","dark":"css-variables"}}
canonical_body = JSON of the request body, keys sorted, no whitespace, non-ASCII escaped
message        = X-API-KEY + "|" + X-TIMESTAMP + "|" + canonical_body
X-SIGNATURE    = Base64( HMAC-SHA256( key = API secret, data = message ) )
```

The canonical body must match exactly what Zapyd produces when it re-serializes the JSON you sent:

| Rule | Detail | |
| - | - | - |
| Key order | Object keys sorted alphabetically, **at every nesting level**. Array order is kept. | |
| Whitespace | None. Separators are `,` and `:` with no spaces. | |
| Non-ASCII | Every character outside printable ASCII is escaped as lowercase `\uXXXX` (`é` → `\u00e9`, emoji → surrogate pair). | |
| GET requests | Always sign `{}`. Query parameters are **not** signed. | |
| Empty body | Sign `{}` for `POST`, `PATCH` or `DELETE` requests with no body. | |
| Separator | A literal pipe \` | \` between key, timestamp and body. |
| Encoding | Standard Base64 of the raw 32-byte digest, not hex. | |

<Tip>
  Send the exact `canonical_body` string you signed as the request body. Then what you signed and what you sent can never drift apart.
</Tip>

## Code examples

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={"theme":{"light":"css-variables","dark":"css-variables"}}
    const crypto = require('crypto');

    // Sorted keys at every level, no whitespace, non-ASCII escaped (matches Python json.dumps)
    function canonicalJson(value) {
      if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
      if (value && typeof value === 'object') {
        return `{${Object.keys(value).sort()
          .map((k) => `${canonicalJson(k)}:${canonicalJson(value[k])}`)
          .join(',')}}`;
      }
      return JSON.stringify(value).replace(
        /[\u007f-￿]/g,
        (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')
      );
    }

    function signRequest(apiKey, apiSecret, body = {}) {
      const timestamp = Math.floor(Date.now() / 1000).toString();
      const canonicalBody = canonicalJson(body);
      const signature = crypto
        .createHmac('sha256', apiSecret)
        .update(`${apiKey}|${timestamp}|${canonicalBody}`)
        .digest('base64');

      return {
        body: canonicalBody, // send this string as the request body
        headers: {
          'Content-Type': 'application/json',
          'X-API-KEY': apiKey,
          'X-TIMESTAMP': timestamp,
          'X-SIGNATURE': signature,
        },
      };
    }
    ```

    <Warning>
      Do not use `JSON.stringify(body, Object.keys(body).sort())`. The replacer array drops nested keys that aren't also top-level keys, and it doesn't escape non-ASCII characters, so the signature fails.
    </Warning>
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
    import base64
    import hashlib
    import hmac
    import json
    import time

    def canonical_json(body):
        # ensure_ascii defaults to True: non-ASCII is escaped as \uXXXX
        return json.dumps(body or {}, sort_keys=True, separators=(",", ":"))

    def sign_request(api_key, api_secret, body=None):
        timestamp = str(int(time.time()))
        canonical_body = canonical_json(body)
        message = f"{api_key}|{timestamp}|{canonical_body}"
        digest = hmac.new(api_secret.encode(), message.encode(), hashlib.sha256).digest()

        return canonical_body, {  # send canonical_body as the request body
            "Content-Type": "application/json",
            "X-API-KEY": api_key,
            "X-TIMESTAMP": timestamp,
            "X-SIGNATURE": base64.b64encode(digest).decode(),
        }
    ```

    <Warning>
      `separators=(",", ":")` is required. The default `json.dumps` adds spaces after `,` and `:`, which changes the signature.
    </Warning>
  </Tab>

  <Tab title="Java">
    ```java theme={"theme":{"light":"css-variables","dark":"css-variables"}}
    import com.fasterxml.jackson.databind.MapperFeature;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.SerializationFeature;
    import com.fasterxml.jackson.databind.json.JsonMapper;
    import javax.crypto.Mac;
    import javax.crypto.spec.SecretKeySpec;
    import java.nio.charset.StandardCharsets;
    import java.util.Base64;
    import java.util.Map;

    public class ZapydSigner {
        // Sorts Map keys and POJO properties at every level; compact output by default
        private static final ObjectMapper MAPPER = JsonMapper.builder()
            .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
            .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
            .build();

        public static String canonicalJson(Object body) throws Exception {
            String json = MAPPER.writeValueAsString(body == null ? Map.of() : body);
            StringBuilder out = new StringBuilder(json.length());
            for (char c : json.toCharArray()) {
                if (c >= 0x7f) out.append(String.format("\\u%04x", (int) c)); // lowercase, like Python
                else out.append(c);
            }
            return out.toString();
        }

        public static String sign(String apiKey, String apiSecret, String timestamp, String canonicalBody) throws Exception {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] digest = mac.doFinal((apiKey + "|" + timestamp + "|" + canonicalBody).getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(digest);
        }
    }

    // Usage
    // String ts = String.valueOf(System.currentTimeMillis() / 1000);
    // String body = ZapydSigner.canonicalJson(requestMap);
    // String signature = ZapydSigner.sign(apiKey, apiSecret, ts, body);
    // -> send body as the request body with X-API-KEY, X-TIMESTAMP, X-SIGNATURE
    ```
  </Tab>
</Tabs>

## Test vector

Check your implementation against these fixed values before calling the API. If your output matches, your signing is correct.

| Input | Value |
| - | - |
| API key | `3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d` |
| API secret | `test-secret-do-not-use` |
| Timestamp | `1735689600` |
| Body | `{"customer_id":"78c99d71-f28f-47a9-8302-93b286efbe0e","amount":100.5,"currency":"INR","meta":{"note":"Café","b":2,"a":1}}` |

| Output | Value |
| - | - |
| Canonical body | `{"amount":100.5,"currency":"INR","customer_id":"78c99d71-f28f-47a9-8302-93b286efbe0e","meta":{"a":1,"b":2,"note":"Caf\u00e9"}}` |
| `X-SIGNATURE` (POST, body above) | `l+DvQrzlKbsOSxSYOdWoWHEehcFRZfDJPKlLDkm2cSI=` |
| `X-SIGNATURE` (GET, signs `{}`) | `6sCtVSRQjU9+2/af8gdwUAvY1l6Ii6ENcbRfanPkhY0=` |

## Example request

```javascript theme={"theme":{"light":"css-variables","dark":"css-variables"}}
const { body, headers } = signRequest(apiKey, apiSecret, {
  full_name: 'John Doe',
  email: 'john@example.com',
  phone: '9876543210',
  alpha_3_country_code: 'IND',
});

const res = await fetch('https://sandbox.zapyd.com/cms/api/v1/customer/create', {
  method: 'POST',
  headers,
  body,
});
```

For a GET request, sign an empty body and send no body:

```javascript theme={"theme":{"light":"css-variables","dark":"css-variables"}}
const { headers } = signRequest(apiKey, apiSecret); // signs "{}"
await fetch(`https://sandbox.zapyd.com/pis/api/v1/payin/${payinId}`, { headers });
```

## Widget Initialize uses a different signature

[Widget Initialize](/api-reference-exchange/endpoint/widget/initialize) is served by the widget service and verifies a different signature. It uses the same three headers, but:

```text theme={"theme":{"light":"css-variables","dark":"css-variables"}}
X-SIGNATURE = lowercase hex( HMAC-SHA256( key = API secret, data = raw_request_body_bytes + X-TIMESTAMP ) )
```

* The raw body bytes exactly as sent, with no re-sorting, followed directly by the timestamp. There's no API key and no `|` separator.
* Hex-encoded, not Base64.
* The timestamp window is also 300 seconds.

```javascript theme={"theme":{"light":"css-variables","dark":"css-variables"}}
const body = JSON.stringify({ flow_type: 'buy', fiat_amount: '1000' });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto.createHmac('sha256', apiSecret).update(body + timestamp).digest('hex');
```

Test vector: secret `test-secret-do-not-use`, timestamp `1735689600`, body `{"flow_type":"buy","fiat_amount":"1000"}` → `26241a01db912ebb26d7f7099958a11ebc50af14937df9d1797a9e89959e7c87`.

## Authentication errors

Auth failures return HTTP `401` with an `err_code`:

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{ "status": false, "message": "Invalid Signature", "data": null, "errors": {}, "err_code": "AUTH_INVALID_SIGNATURE" }
```

| `err_code` | Cause | Fix |
| - | - | - |
| `AUTH_MISSING_HEADERS` | One of the three headers is missing | Send `X-API-KEY`, `X-TIMESTAMP` and `X-SIGNATURE` on every request |
| `AUTH_INVALID_TIMESTAMP` | `X-TIMESTAMP` is not an integer | Send Unix seconds, not milliseconds or ISO dates |
| `AUTH_TIMESTAMP_EXPIRED` | More than 300s from server time | Generate the timestamp per request, sync your clock |
| `AUTH_INVALID_API_KEY` | Unknown, disabled or malformed key, or a sandbox key used in production (or the reverse) | Check the key and the environment |
| `AUTH_INVALID_SIGNATURE` | Signature mismatch | Run the test vector above. Usual causes: whitespace in JSON, unsorted nested keys, signing a GET body other than `{}`, hex instead of Base64 |
| `AUTH_ORG_DISABLED` | Your organization is disabled | Contact support |
| `AUTH_ROUTE_FORBIDDEN` | Route is not available to partners | Check the endpoint path and module prefix |

## Security

* Keep the API secret on your server. Never ship it to browsers or mobile apps.
* Generate a fresh timestamp and signature for every request.
* Rotate credentials immediately if you suspect a leak.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.