Appearance
Authentication
How to authenticate with the EZYS API.
Two credentials are issued together, and they do different jobs:
| Credential | Sent? | Purpose |
|---|---|---|
API key (ezys_{env}_…) | Yes, as X-API-Key | Identifies the caller on every request |
| Signing secret | Never | Signs the requests that move value — see Request Signing |
Most endpoints need the API key alone. A named set — submitting an intent, creating an order, requesting a payout — needs a signature as well.
API Key
All API requests require a valid API key, passed via the X-API-Key header.
http
X-API-Key: {your_api_key}Example
bash
curl -X POST "https://api.ezys.io/v1/quotes" \
-H "X-API-Key: {your_api_key}" \
-H "Content-Type: application/json" \
-d '{ "requestId": "RFQ-001", "actorId": "acme", "source": "USDC", "target": "KRW", "amount": "1000", "side": "sell" }'Request Signing
Some endpoints require an HMAC-SHA256 signature in addition to X-API-Key. The signature proves the request came from the holder of the signing secret and has not been altered in transit. The secret itself is never transmitted.
An unsigned request to a signed endpoint is rejected — the API key alone is not sufficient.
Signed endpoints
Signing is required per endpoint, not by HTTP method. Of the endpoints in the contract, one requires it:
| Endpoint | Scope |
|---|---|
POST /intents | intents:write |
Every other endpoint in the contract takes X-API-Key alone. That includes both quote endpoints — POST /quotes and POST /quotes/firm — even though a firm quote is persisted and locks a rate for its TTL.
Surfaces outside the contract have their own signing requirements, so "not in the table above" means "not signed" only for the endpoints published in this reference.
Sign anyway if you can
Nothing rejects a signature on an endpoint that does not require one. If your client signs uniformly, keep doing so: it costs nothing and survives an endpoint joining the list above.
Headers
| Header | Required | Description |
|---|---|---|
X-Signature | Yes | Lowercase hex HMAC-SHA256 digest (64 characters) of the canonical string |
X-Timestamp | Yes | Unix epoch seconds. Accepted within ±300 seconds of server time |
X-Nonce | Yes | 16–64 characters. Fresh per request — see Replay protection |
X-Idempotency-Key | Yes | Retry-safety key — see Idempotency |
Canonical string
Concatenate five values in this order, with no separator between them — no newlines, no spaces, no delimiters of any kind:
HTTP method (uppercase)
+ request path including query string
+ X-Timestamp
+ X-Nonce
+ request bodySign the full path, including /v1
The path is the whole path component of the URL you call, not the endpoint suffix — /v1/intents, not /intents. The version segment is part of the path and therefore part of the signature.
For POST /v1/intents with timestamp 1737123456, nonce a3f1… and body {"intentId":"INT-1"}:
POST/v1/intents1737123456a3f1…{"intentId":"INT-1"}Sign that string with the secret as the key — the RFC 2104 argument order:
X-Signature = hex( HMAC-SHA256( key = signing_secret, message = canonical_string ) )The body is part of the message, not hashed separately: the raw UTF-8 body is appended to the canonical string and the whole string is signed once.
Two details decide whether a signature verifies:
- The body is signed byte for byte, exactly as sent. Re-serializing your JSON before signing — or signing a pretty-printed copy of a minified payload — changes the message and therefore the digest. Build the body string once, sign that string, and send that same string. A request with no body contributes an empty string in that position.
- The path is signed as sent, including the query string. If you route through a proxy that rewrites paths, sign the path the API receives.
Example
The bodies below are abbreviated to keep the signing steps readable — they are not valid intents. See Intent API for the required fields; the signing procedure is identical whatever the body contains.
bash
TS=$(date +%s) # epoch SECONDS
NONCE=$(openssl rand -hex 16) # 32 chars, fresh per request
BODY='{"intentId":"INT-2F8A3B1C","…":"…"}' # abbreviated
# The path signed here must be the path sent below, `/v1` included.
CANON="POST/v1/intents${TS}${NONCE}${BODY}"
SIG=$(printf %s "$CANON" \
| openssl dgst -sha256 -hmac "$EZYS_SIGNING_SECRET" -hex \
| sed 's/^.* //')
curl -X POST "https://api.ezys.io/v1/intents" \
-H "X-API-Key: $EZYS_API_KEY" \
-H "X-Signature: $SIG" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "$BODY"javascript
import crypto from 'node:crypto'
import { randomUUID } from 'node:crypto'
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomBytes(16).toString('hex')
// Serialize ONCE. Sign this exact string and send this exact string.
const body = JSON.stringify({ intentId: 'INT-2F8A3B1C' /* … */ })
// The path signed here must be the path requested below, `/v1` included.
const path = '/v1/intents'
const canonical = `POST${path}${ts}${nonce}${body}`
const signature = crypto
.createHmac('sha256', process.env.EZYS_SIGNING_SECRET) // secret is the KEY
.update(canonical, 'utf8') // canonical string is the MESSAGE
.digest('hex')
await fetch(`https://api.ezys.io${path}`, {
method: 'POST',
headers: {
'X-API-Key': process.env.EZYS_API_KEY,
'X-Signature': signature,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Idempotency-Key': randomUUID(),
'Content-Type': 'application/json'
},
body
})Replay protection
X-Timestamp is the guarantee. A timestamp more than 300 seconds from server time is rejected, so a captured request is unusable outside that window regardless of anything else.
X-Nonce is defence in depth on top of it. Within the window, a nonce already seen for that API key is rejected. The check is held per API instance and is not shared across the fleet, so it does not by itself make replay impossible across instances or restarts — treat it as narrowing the window, not closing it, and keep the ±300s bound as the property you rely on.
Generate a fresh nonce per request. Do not derive it from the payload, and do not reuse one across a retry: use X-Idempotency-Key for retry safety, which is what it is for.
Authentication Errors
Missing API Key
http
HTTP/1.1 401 Unauthorized
{
"success": false,
"error": {
"code": "MISSING_API_KEY",
"message": "API key is required"
}
}Invalid API Key
http
HTTP/1.1 401 Unauthorized
{
"success": false,
"error": {
"code": "INVALID_API_KEY",
"message": "Invalid API key"
}
}Disabled API Key
http
HTTP/1.1 403 Forbidden
{
"success": false,
"error": {
"code": "API_KEY_DISABLED",
"message": "This API key has been disabled"
}
}Invalid Signature
Returned for every signing failure — a missing or malformed header, a timestamp outside the window, a reused nonce, or a digest that does not match. The message names which one.
http
HTTP/1.1 401 Unauthorized
{
"success": false,
"error": {
"code": "INVALID_SIGNATURE",
"message": "Signature verification failed"
}
}If a signature that looks correct is rejected, the cause is almost always the request body: sign the exact bytes you send. See Canonical string.
Key Rotation
When rotating keys, a new key can be issued while the existing key remains active, allowing for zero-downtime migration. Once the new key is deployed, the previous key can be deactivated.
