Vouchereto API · v1
Authentication & signing
Every request carries your API key and a signature of the request made with that key's secret. A stolen key alone, or a request changed in transit, is rejected.
Headers on every request
| Header | Value |
|---|---|
Authorization | Bearer vch_test_… |
Vouchereto-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Idempotency-Key | Every POST: 1–128 characters, unique per operation. Details |
Content-Type | application/json on requests with a body |
X-Customer-Ip | Optional, on POST /v1/redemptions: your customer's IP, for fraud limits |
The signature
- Take the secret: the part of your API key after the last
_(32 characters). - Take
t, the current unix time in seconds. - Build the string
t + "." + METHOD + " " + path_with_query + "\n" + raw_body:METHODin upper case:GETorPOST.path_with_querystarts with/v1and includes the query string exactly as sent, for example/v1/redemptions?status=captured&limit=20.raw_bodyis the exact bytes you send. GET requests and POSTs without a body use an empty string (the newline is still there).
- Compute HMAC-SHA256 of that string with the secret, hex-encode it (lower case, 64 characters).
- Send
Vouchereto-Signature: t=<t>,v1=<hex>.
worked example
API key vch_test_AbC123dEf456_Q2w3E4r5T6y7U8i9O0p1A2s3D4f5G6h7
secret Q2w3E4r5T6y7U8i9O0p1A2s3D4f5G6h7 (last "_" segment)
t 1792663200 (unix seconds, now)
method + path POST /v1/redemptions
body {"code":"K7QM-4XPD-9TRW-2WNZ","currency":"USD","customer_ref":"user_8842"}
string to sign (the \n is one newline character):
1792663200.POST /v1/redemptions\n{"code":"K7QM-4XPD-9TRW-2WNZ","currency":"USD","customer_ref":"user_8842"}
Vouchereto-Signature: t=1792663200,v1=<hex HMAC-SHA256(secret, string to sign)>
Sign and send
# Bash + openssl: sign and send a request
KEY="$VOUCHERETO_API_KEY" # vch_test_…
API="${VOUCHERETO_API:-https://api.vouchereto.com}"
SECRET="${KEY##*_}" # the secret is the last "_" segment of the key
METHOD="POST"
PATH_Q="/v1/sandbox/vouchers" # path (with /v1 and any ?query)
BODY='{"state":"active","amount_minor":5000,"currency":"USD"}'
T=$(date +%s)
SIG=$(printf '%s.%s %s\n%s' "$T" "$METHOD" "$PATH_Q" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS -X "$METHOD" "$API$PATH_Q" \
-H "Authorization: Bearer $KEY" \
-H "Vouchereto-Signature: t=$T,v1=$SIG" \
-H "Idempotency-Key: $(openssl rand -hex 16)" \
-H "Content-Type: application/json" \
--data-raw "$BODY"
// Node 18+, no dependencies
import { createHmac, randomUUID } from 'node:crypto';
const API = process.env.VOUCHERETO_API ?? 'https://api.vouchereto.com';
const KEY = process.env.VOUCHERETO_API_KEY; // vch_test_…
const SECRET = KEY.split('_').at(-1); // last "_" segment
async function vouchereto(method, path, body) {
const raw = body === undefined ? '' : JSON.stringify(body);
const t = Math.floor(Date.now() / 1000);
const mac = createHmac('sha256', SECRET).update(`${t}.${method} ${path}\n${raw}`).digest('hex');
const headers = { Authorization: `Bearer ${KEY}`, 'Vouchereto-Signature': `t=${t},v1=${mac}` };
if (method === 'POST') Object.assign(headers, { 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID() });
const res = await fetch(API + path, { method, headers, body: raw || undefined });
const json = await res.json();
if (!res.ok) throw new Error(`${json.code} ${json.detail ?? ''} (request ${json.request_id})`);
return json;
}
console.log(await vouchereto('POST', '/v1/sandbox/vouchers', { state: 'active', amount_minor: 5000, currency: 'USD' }));
# Python 3.9+ with requests
import hashlib, hmac, json, os, time, uuid
import requests
API = os.environ.get("VOUCHERETO_API", "https://api.vouchereto.com")
KEY = os.environ["VOUCHERETO_API_KEY"] # vch_test_…
SECRET = KEY.rsplit("_", 1)[1] # last "_" segment
def vouchereto(method, path, body=None):
raw = "" if body is None else json.dumps(body, separators=(",", ":"))
t = str(int(time.time()))
mac = hmac.new(SECRET.encode(), f"{t}.{method} {path}\n{raw}".encode(), hashlib.sha256).hexdigest()
headers = {"Authorization": f"Bearer {KEY}", "Vouchereto-Signature": f"t={t},v1={mac}"}
if method == "POST":
headers.update({"Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4())})
# send exactly the bytes you signed: data=raw, not json=body
r = requests.request(method, API + path, headers=headers, data=raw.encode() if raw else None, timeout=15)
out = r.json()
if r.status_code >= 400:
raise RuntimeError(f"{out['code']} {out.get('detail', '')} (request {out.get('request_id')})")
return out
print(vouchereto("POST", "/v1/sandbox/vouchers", {"state": "active", "amount_minor": 5000, "currency": "USD"}))
<?php
// PHP 8 with ext-curl
function vouchereto(string $method, string $path, ?array $body = null): array {
$api = getenv('VOUCHERETO_API') ?: 'https://api.vouchereto.com';
$key = getenv('VOUCHERETO_API_KEY'); // vch_test_…
$secret = substr($key, strrpos($key, '_') + 1); // last "_" segment
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
$t = time();
$sig = hash_hmac('sha256', "$t.$method $path\n$raw", $secret);
$headers = ["Authorization: Bearer $key", "Vouchereto-Signature: t=$t,v1=$sig"];
if ($method === 'POST') {
$headers[] = 'Content-Type: application/json';
$headers[] = 'Idempotency-Key: ' . bin2hex(random_bytes(16));
}
$ch = curl_init($api . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true]);
if ($raw !== '') curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
$res = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode($res, true);
if ($status >= 400) throw new RuntimeException("{$json['code']} (request {$json['request_id']})");
return $json;
}
print_r(vouchereto('POST', '/v1/sandbox/vouchers', ['state' => 'active', 'amount_minor' => 5000, 'currency' => 'USD']));
These samples are executed against the API in our CI on every change, so you can copy them as they are.
Common mistakes
| Symptom | Cause |
|---|---|
401 INVALID_SIGNATURE · MISMATCH | Body re-serialised after signing (sign the exact string you send: do not pass an object to your HTTP client after signing a string), path without /v1, query missing or reordered, lower-case method, or the whole key used as the secret. |
401 INVALID_SIGNATURE · TIMESTAMP_OUT_OF_RANGE | Your clock is more than 300 seconds off, or you reused an old signature. Sign each request just before sending and run NTP. |
401 INVALID_SIGNATURE · MALFORMED | Header missing, or not in the form t=…,v1=<64 hex>. |
401 UNAUTHENTICATED | Key missing, mistyped, revoked, or a key of another environment. |
403 MERCHANT_NOT_ACTIVE | A live key before your account is live, or a suspended account. |
Too many failed authentications from one IP return 429 RATE_LIMITED for a minute.
Rotating keys
Create a second key in the portal, deploy it, check traffic in the portal's API log, then revoke the old key. Both work in the meantime. The signature header may carry several v1= values; the request passes if any of them matches.