Authentication
The TEDR Pay API takes two kinds of caller. The dashboard signs in as a session and sends a JWT; your own backend signs every request with an HMAC built from the API key's secretKey. A route that takes both decides from the request itself — if a KeyId header is present, it checks the signature.
Getting a JWT
A JWT is the access token your TEDR Pay dashboard session carries. Every endpoint in this reference marked Session JWT only — the API key management routes among them — accepts that token and nothing else, so an API key signature will not reach them.
Issuing a JWT outside the dashboard is not covered here yet.
This version of the reference does not document the sign-in flow that mints a session token, so a server-to-server integration cannot reach the JWT-only routes. Sign with an API key for everything your backend calls.
Signing with an API key
Your backend holds no session. It signs each request with an HMAC over the key's secretKey and sends the result in four headers. Issue and rotate the key itself from the API keys reference.
Request headers
| Header | Value |
|---|---|
| KeyId | The key’s privateKey. Its presence is what switches the route from session auth to signature checking. |
| Timestamp | The current Unix time in milliseconds, as a string. |
| Algorithm | One of sha1, sha256, sha384 or sha512. |
| X-Signature | The HMAC hex digest. |
Content-Type: application/json is required too, though it is not checked on its own: with any other value the body does not parse and the signature fails by itself.
What gets signed
The signed string is a concatenation with no separators: Timestamp + originalUrl + body. Here originalUrl is the path together with its query string, exactly as it goes out in the request — /invoices?limit=5. When there is no body, nothing is appended.
Sign the path exactly as your request carries it — the same characters, in the same order, that go out on the wire.
Whether a base URL prefix belongs in the signed path is not confirmed.
The signed string is the path our server sees, and a proxy in front of it can strip a prefix before the request arrives. This version of the reference does not confirm which of the two the API compares against. The samples below sign the prefix along with the rest of the path. If your first signed request comes back 401 SIGNATURE_INVALID and everything else checks out, sign the path without the prefix and try again.
The server checks your signature against two forms of the body: first the raw bytes it received, then JSON.stringify of the parsed JSON. The second form is what Node clients produce; the first is there for everyone else, because Python's json.dumps puts a space after : and , by default, and Go's encoder escapes three characters by default, so the bytes stop matching what V8 would have written: <, >, &
Serialize the body once.
Build the body as a string, sign that string, and send that same string. Handing the object to a client that serializes it again — the json= argument of Python's requests, for one — produces different bytes and a 401 SIGNATURE_INVALID. When a request fails that way, compare the exact string you signed with the exact bytes you sent.
Signing a request
PRIVATE_KEY and SECRET_KEY below stand for the key's privateKey and secretKey. secretKey never leaves your server.
# PRIVATE_KEY and SECRET_KEY hold the key’s privateKey and secretKey.
URL='/api/invoices'
BODY='{"type":"PAYME","amount":"150"}'
TIMESTAMP="$(($(date +%s) * 1000))"
SIGNATURE="$(printf '%s' "$TIMESTAMP$URL$BODY" \
| openssl dgst -sha256 -hmac "$SECRET_KEY" -r \
| cut -d ' ' -f 1)"
curl -X POST "https://dev-app.tedr.com$URL" \
-H 'Content-Type: application/json' \
-H "KeyId: $PRIVATE_KEY" \
-H "Timestamp: $TIMESTAMP" \
-H 'Algorithm: sha256' \
-H "X-Signature: $SIGNATURE" \
-d "$BODY"
import { createHmac } from 'node:crypto';
// Sign the path exactly as it leaves the client, prefix and query string included.
const url = '/api/invoices';
const timestamp = String(Date.now());
const body = JSON.stringify({ type: 'PAYME', amount: '150' });
const signature = createHmac('sha256', SECRET_KEY)
.update(timestamp + url + body)
.digest('hex');
const response = await fetch('https://dev-app.tedr.com' + url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
KeyId: PRIVATE_KEY,
Timestamp: timestamp,
Algorithm: 'sha256',
'X-Signature': signature,
},
// The string that was signed, not the object it came from.
body,
});
import hashlib
import hmac
import json
import time
import requests
# Sign the path exactly as it leaves the client, prefix and query string included.
url = '/api/invoices'
timestamp = str(int(time.time() * 1000))
# Serialize once: json.dumps pads with spaces by default, so sign this exact string.
body = json.dumps({'type': 'PAYME', 'amount': '150'}, separators=(',', ':'))
signature = hmac.new(
SECRET_KEY.encode(),
(timestamp + url + body).encode(),
hashlib.sha256,
).hexdigest()
response = requests.post(
'https://dev-app.tedr.com' + url,
headers={
'Content-Type': 'application/json',
'KeyId': PRIVATE_KEY,
'Timestamp': timestamp,
'Algorithm': 'sha256',
'X-Signature': signature,
},
# data=, not json=: json= re-serializes the payload and the signature stops matching.
data=body,
)
Where signatures are accepted
These are the routes that check an API key signature; everything else in the API expects a session JWT. Paths are relative to the base URL.
| Route | Methods |
|---|---|
| /invoices | GET, POST |
| /invoices/:id | GET, DELETE |
| /invoices/:id/payments | GET |
| /invoices/:id/cancel | POST |
| /invoices/:id/reactivate | POST |
| /merchants/me | GET, PATCH |
| /transactions | GET |
Two rows carry a caveat. /merchants/me accepts a signature, but this version of the reference does not describe that route. /transactions is documented as accepting one on the Transactions page, which is not where the rest of this list comes from — so it is the row here that is least firmly settled.
Keeping signed requests safe
Timestamphas to stay close to the server clock — five minutes by default, in either direction. Outside that window the request is rejected with 401TIMESTAMP_EXPIRED.- Inside that window a captured request can be replayed: the server keeps no record of signatures it has already accepted. Send every request over HTTPS.
- Keep signed requests out of your logs. The signature covers the body as well, so the headers and the body together are enough to repeat the request until the timestamp ages out.
A failed signature check always answers 401. The Errors reference lists UNAUTHORIZED, TIMESTAMP_EXPIRED, API_KEY_INVALID and SIGNATURE_INVALID with the condition behind each one.
