HSP Wallet
Merchant payment API
This API lets a merchant website collect euros into an HSP account. The customer is redirected to an HSP page, enters the phone number and PIN from the HSP Wallet app, then returns to your site. The PIN is never sent to your server.
1. Sign in to the merchant desk: https://comptehsp.com/commercant (app phone + PIN).
2. Open the “Website payment API” card.
3. Click “Get key”. HSP shows two values:
- key_id (public), starts with hspk_
- secret (private), starts with hsps_
4. Copy the secret immediately. It is never shown again. If you lose it, “Regenerate” creates a new pair and invalidates the old one.
5. “Revoke” turns the API off.
The desk and these docs are available in French, English, Spanish, German, Portuguese and Arabic: /pay/checkout/docs?lang=fr|en|es|de|pt|ar
Every merchant request sends three headers:
- X-HSP-Key: the key_id
- X-HSP-Timestamp: Unix time in seconds
- X-HSP-Signature: lowercase hex HMAC-SHA256
Signed message, four lines separated by \n:
timestamp
METHOD
path
sha256_hex(body)
Base URL: https://comptehsp.com
POST /api/v1/checkout/payments
| Field | Required | Description |
|---|---|---|
| reference | yes | Your order id, unique. 1–80 characters: letters, digits, . _ : - |
| amount | yes | Amount in EUR, from 0.01 to 50000. JSON number, not a string. |
| currency | yes | EUR only |
| return_url | yes | Public https URL where the customer returns after payment |
| cancel_url | no | Public https URL if the customer cancels |
| webhook_url | no | Public https URL that receives the result |
| description | no | Label shown to the customer, 180 characters max |
| expires_in | no | Link lifetime in seconds, 60 to 86400. Default: 1800 (30 min) |
| locale | no | Checkout page language: fr, en, es, de, pt, ar |
| customer.email | no | Stored for your records |
| customer.name | no | Free name, 120 characters max |
| metadata | no | Free JSON object, 2000 characters max |
201 response:
- id: HSP id (pay_…)
- status: PENDING
- checkout_url: open this in the customer’s browser
- expires_at: expiry time
- already_exists: true when that reference was already pending
Send the customer to checkout_url. They pay on the HSP domain. HSP then redirects to return_url with hsp_payment_id, hsp_reference and hsp_status. Do not trust those query parameters alone: confirm with the status endpoint or the signed webhook.
A merchant reference can be debited only once.
- Still-valid PENDING: the same link is returned, no second payment.
- Expired PENDING, CANCELLED or FAILED with no debit: the same reference is reopened with a new link.
- PAID or a debit in progress: HTTP 409, code already_paid. No new debit.
- Confirmation takes a lock: two simultaneous confirms debit only once.
- After expiry the status becomes EXPIRED and the form is rejected (HTTP 410, code payment_expired).
- Five wrong PINs on the link block that link. Five wrong PINs on the HSP account lock it for 15 minutes.
Statuses: PENDING, PROCESSING, PAID, EXPIRED, CANCELLED, FAILED.
GET /api/v1/checkout/payments/{id}
GET /api/v1/checkout/payments/reference/{reference}
Same HMAC signature, empty body. Treat the order as paid only when status is PAID.
HSP POSTs JSON to webhook_url when the payment becomes PAID, EXPIRED, CANCELLED or FAILED.
It retries up to 6 times unless your server answers 2xx.
Headers:
- X-HSP-Event: checkout.payment.paid (or .expired, .cancelled, .failed)
- X-HSP-Timestamp: Unix seconds
- X-HSP-Signature: HMAC-SHA256 of timestamp + newline + the raw body you received
Body:
{
"event": "checkout.payment.paid",
"created_at": "2026-10-19T12:00:00.000Z",
"payment": {
"id": "pay_…",
"reference": "ORDER-1001",
"status": "PAID",
"amount": 10.5,
"currency": "EUR",
"description": "Order 1001",
"paid_at": "…",
"payer_label": "Awa D.",
"ledger_ref": "…",
"metadata": { "cart": "1001" }
}
}
Compare the signature with the raw body, not with re-encoded JSON. Reject a timestamp older or newer than 5 minutes. Check that payment.reference and payment.amount match your order, and that status is PAID, before you fulfill it.
| Code | HTTP | Meaning |
|---|---|---|
| missing_signature | 401 | Header missing |
| invalid_signature | 401 | Bad HMAC |
| stale_timestamp | 401 | Clock outside the window |
| replayed_signature | 401 | Signature already used |
| unknown_api_key | 401 | Unknown key_id |
| revoked_api_key | 401 | Key revoked |
| already_paid | 409 | Reference already paid or in progress |
| reference_conflict | 409 | Same reference, different amount |
| payment_expired | 410 | Link expired |
| invalid_amount | 400 | Amount rejected |
| invalid_currency | 400 | Currency is not EUR |
| invalid_url | 400 | URL is not public https |
| invalid_pin | 400 | Phone or PIN is wrong |
| insufficient_balance | 400 | Customer balance too low |
| too_many_attempts | 429 | Too many PIN attempts |
For Node.js, PHP and Python, set HSP_KEY (hspk_…) and HSP_SECRET (hsps_…). On WordPress, paste them in Settings → HSP Paiement.
const crypto = require('crypto');
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
}
function sign({ secret, timestamp, method, path, body }) {
const payload = body == null ? '' : canonical(body);
const message = [timestamp, method.toUpperCase(), path, crypto.createHash('sha256').update(payload).digest('hex')].join('\n');
return crypto.createHmac('sha256', secret).update(message).digest('hex');
}
const body = {
reference: 'ORDER-' + Date.now(),
amount: 1,
currency: 'EUR',
description: 'Commande de test',
return_url: 'https://shop.example/hsp/return',
cancel_url: 'https://shop.example/hsp/cancel',
webhook_url: 'https://shop.example/hsp/webhook',
expires_in: 600,
locale: 'fr',
metadata: { cart: '1001' }
};
const timestamp = String(Math.floor(Date.now() / 1000));
const path = '/api/v1/checkout/payments';
const signature = sign({ secret: process.env.HSP_SECRET, timestamp, method: 'POST', path, body });
fetch('https://comptehsp.com' + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-HSP-Key': process.env.HSP_KEY,
'X-HSP-Timestamp': timestamp,
'X-HSP-Signature': signature
},
body: JSON.stringify(body)
}).then(async (res) => {
const payment = await res.json();
console.log(res.status, payment.checkout_url || payment);
});<?php
function hsp_canonical($value) {
if (!is_array($value)) {
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$isList = function_exists('array_is_list')
? array_is_list($value)
: ($value === [] || array_keys($value) === range(0, count($value) - 1));
if ($isList) {
return '[' . implode(',', array_map('hsp_canonical', $value)) . ']';
}
ksort($value);
$parts = [];
foreach ($value as $key => $item) {
$parts[] = json_encode((string) $key, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ':' . hsp_canonical($item);
}
return '{' . implode(',', $parts) . '}';
}
function hsp_sign($secret, $timestamp, $method, $path, $body) {
$payload = $body === null ? '' : hsp_canonical($body);
$message = $timestamp . "\n" . strtoupper($method) . "\n" . $path . "\n" . hash('sha256', $payload);
return hash_hmac('sha256', $message, $secret);
}
$key = getenv('HSP_KEY');
$secret = getenv('HSP_SECRET');
$body = [
'reference' => 'ORDER-' . time(),
'amount' => 1,
'currency' => 'EUR',
'description' => 'Commande de test',
'return_url' => 'https://shop.example/hsp/return',
'cancel_url' => 'https://shop.example/hsp/cancel',
'webhook_url' => 'https://shop.example/hsp/webhook',
'expires_in' => 600,
'locale' => 'fr',
'metadata' => ['cart' => '1001'],
];
$path = '/api/v1/checkout/payments';
$timestamp = (string) time();
$signature = hsp_sign($secret, $timestamp, 'POST', $path, $body);
$ch = curl_init('https://comptehsp.com' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-HSP-Key: ' . $key,
'X-HSP-Timestamp: ' . $timestamp,
'X-HSP-Signature: ' . $signature,
],
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$payment = json_decode($response, true);
echo $code . ' ' . ($payment['checkout_url'] ?? $response) . PHP_EOL;import hashlib, hmac, json, os, time, urllib.request
def canonical(value):
if isinstance(value, dict):
parts = [
json.dumps(key, ensure_ascii=False) + ":" + canonical(value[key])
for key in sorted(value)
]
return "{" + ",".join(parts) + "}"
if isinstance(value, list):
return "[" + ",".join(canonical(item) for item in value) + "]"
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def sign(secret, timestamp, method, path, body):
payload = "" if body is None else canonical(body)
message = "\n".join([timestamp, method.upper(), path, hashlib.sha256(payload.encode()).hexdigest()])
return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
body = {
"reference": "ORDER-" + str(int(time.time())),
"amount": 1,
"currency": "EUR",
"description": "Commande de test",
"return_url": "https://shop.example/hsp/return",
"cancel_url": "https://shop.example/hsp/cancel",
"webhook_url": "https://shop.example/hsp/webhook",
"expires_in": 600,
"locale": "fr",
"metadata": {"cart": "1001"},
}
path = "/api/v1/checkout/payments"
timestamp = str(int(time.time()))
signature = sign(os.environ["HSP_SECRET"], timestamp, "POST", path, body)
req = urllib.request.Request(
"https://comptehsp.com" + path,
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"X-HSP-Key": os.environ["HSP_KEY"],
"X-HSP-Timestamp": timestamp,
"X-HSP-Signature": signature,
},
method="POST",
)
with urllib.request.urlopen(req) as res:
payment = json.loads(res.read().decode())
print(res.status, payment.get("checkout_url"))const reference = 'ORDER-1001';
const path = '/api/v1/checkout/payments/reference/' + encodeURIComponent(reference);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = sign({ secret: process.env.HSP_SECRET, timestamp, method: 'GET', path, body: null });
fetch('https://comptehsp.com' + path, {
headers: {
'X-HSP-Key': process.env.HSP_KEY,
'X-HSP-Timestamp': timestamp,
'X-HSP-Signature': signature
}
}).then(async (res) => console.log(res.status, await res.json()));<?php
$reference = 'ORDER-1001';
$path = '/api/v1/checkout/payments/reference/' . rawurlencode($reference);
$timestamp = (string) time();
$signature = hsp_sign(getenv('HSP_SECRET'), $timestamp, 'GET', $path, null);
$ch = curl_init('https://comptehsp.com' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-HSP-Key: ' . getenv('HSP_KEY'),
'X-HSP-Timestamp: ' . $timestamp,
'X-HSP-Signature: ' . $signature,
],
]);
echo curl_exec($ch) . PHP_EOL;
curl_close($ch);from urllib.parse import quote
reference = "ORDER-1001"
path = "/api/v1/checkout/payments/reference/" + quote(reference, safe="")
timestamp = str(int(time.time()))
signature = sign(os.environ["HSP_SECRET"], timestamp, "GET", path, None)
req = urllib.request.Request(
"https://comptehsp.com" + path,
headers={
"X-HSP-Key": os.environ["HSP_KEY"],
"X-HSP-Timestamp": timestamp,
"X-HSP-Signature": signature,
},
)
with urllib.request.urlopen(req) as res:
print(json.loads(res.read().decode())["status"])const crypto = require('crypto');
function verifyWebhook(secret, rawBody, timestamp, signature) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(timestamp + '\n' + rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(String(signature || ''), 'hex');
return a.length === b.length && a.length === 32 && crypto.timingSafeEqual(a, b);
}
// Express : app.use(express.raw({ type: 'application/json' })) sur cette route
app.post('/hsp/webhook', (req, res) => {
const raw = req.body.toString('utf8');
const ok = verifyWebhook(process.env.HSP_SECRET, raw, req.get('x-hsp-timestamp'), req.get('x-hsp-signature'));
if (!ok) return res.status(401).end();
const event = JSON.parse(raw);
if (event.payment.status === 'PAID' && event.payment.amount === expectedAmount) {
// livrer la commande une seule fois (idempotence sur payment.reference)
}
res.status(200).json({ received: true });
});<?php
$secret = getenv('HSP_SECRET');
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_HSP_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_HSP_SIGNATURE'] ?? '';
$now = time();
if (!ctype_digit($timestamp) || abs($now - intval($timestamp)) > 300) {
http_response_code(401);
exit;
}
$expected = hash_hmac('sha256', $timestamp . "\n" . $raw, $secret);
if (!hash_equals($expected, strtolower($signature))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if (($event['payment']['status'] ?? '') === 'PAID') {
// livrer une seule fois selon payment.reference
}
http_response_code(200);
echo json_encode(['received' => true]);import hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["HSP_SECRET"].encode()
@app.post("/hsp/webhook")
def webhook():
raw = request.get_data()
timestamp = request.headers.get("X-HSP-Timestamp", "")
signature = request.headers.get("X-HSP-Signature", "")
try:
ts = int(timestamp)
except ValueError:
return ("", 401)
if abs(int(time.time()) - ts) > 300:
return ("", 401)
expected = hmac.new(SECRET, timestamp.encode() + b"\n" + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature.lower()):
return ("", 401)
event = json.loads(raw)
if event["payment"]["status"] == "PAID":
pass # livrer une seule fois selon payment["reference"]
return {"received": True}