HSP Wallet
API paiement marchand
Cette API permet à un site marchand d’encaisser en euros sur un compte HSP. Le client est redirigé vers une page HSP, saisit le téléphone et le PIN de son application HSP Wallet, puis revient sur votre site. Le PIN n’est jamais envoyé à votre serveur.
1. Connectez-vous à l’espace commerçant : https://comptehsp.com/commercant (téléphone + PIN de l’application).
2. Ouvrez la carte « API paiement site web ».
3. Cliquez sur « Obtenir la clé ». HSP affiche deux valeurs :
- key_id (public), commence par hspk_
- secret (privé), commence par hsps_
4. Copiez le secret immédiatement. Il n’est plus jamais relu. En cas de perte, « Régénérer » crée une nouvelle paire et invalide l’ancienne.
5. « Révoquer » coupe l’API sans la remplacer.
La même page existe en français, anglais, espagnol, allemand, portugais et arabe (sélecteur de langue). La documentation suit la langue : /pay/checkout/docs?lang=fr|en|es|de|pt|ar
Chaque requête marchande porte trois en-têtes :
- X-HSP-Key : le key_id
- X-HSP-Timestamp : heure Unix en secondes
- X-HSP-Signature : HMAC-SHA256 hexadécimal
Message signé, quatre lignes séparées par un saut de ligne (\n) :
timestamp
METHOD
path
sha256_hex(corps)
Base : https://comptehsp.com
POST /api/v1/checkout/payments
| Champ | Obligatoire | Description |
|---|---|---|
| reference | oui | Votre numéro de commande, unique. 1 à 80 caractères : lettres, chiffres, . _ : - |
| amount | oui | Montant en EUR, de 0,01 à 50000. Nombre JSON, pas une chaîne. |
| currency | oui | Uniquement EUR |
| return_url | oui | URL https publique où renvoyer le client après paiement |
| cancel_url | non | URL https publique si le client annule |
| webhook_url | non | URL https publique qui reçoit le résultat |
| description | non | Libellé affiché au client, 180 caractères max |
| expires_in | non | Durée de vie du lien en secondes, de 60 à 86400. Défaut : 1800 (30 min) |
| locale | non | Langue de la page client : fr, en, es, de, pt, ar |
| customer.email | non | Mémorisé pour votre suivi, non affiché comme identifiant de débit |
| customer.name | non | Nom libre, 120 caractères max |
| metadata | non | Objet JSON libre, 2000 caractères max |
Réponse 201 :
- id : identifiant HSP (pay_…)
- status : PENDING
- checkout_url : lien à ouvrir dans le navigateur du client
- expires_at : fin de validité
- already_exists : true si la même référence était déjà en attente
Redirigez le client vers checkout_url. Il paie sur le domaine HSP. Ensuite HSP le renvoie vers return_url avec hsp_payment_id, hsp_reference et hsp_status. Ces paramètres ne suffisent pas : confirmez toujours avec l’endpoint de statut ou le webhook signé.
Une référence marchand ne peut être débitée qu’une seule fois.
- PENDING encore valide : le même lien est renvoyé, sans second paiement.
- PENDING expiré, CANCELLED ou FAILED sans débit : la même référence est réouverte avec un nouveau lien.
- PAID ou débit en cours : HTTP 409, code already_paid. Aucun nouveau débit.
- Le passage en paiement utilise un verrou : deux confirmations simultanées ne débitent qu’une fois.
- À expiration, le statut devient EXPIRED et le formulaire est refusé (HTTP 410, code payment_expired).
- Cinq PIN incorrects sur le lien bloquent ce lien. Cinq PIN incorrects sur le compte HSP le verrouillent 15 minutes.
Statuts : PENDING, PROCESSING, PAID, EXPIRED, CANCELLED, FAILED.
GET /api/v1/checkout/payments/{id}
GET /api/v1/checkout/payments/reference/{reference}
Même signature HMAC, corps vide. Ne considérez la commande payée que si status vaut PAID.
HSP envoie un POST JSON à webhook_url quand le paiement devient PAID, EXPIRED, CANCELLED ou FAILED.
Nouvel essai jusqu’à 6 fois si votre serveur ne répond pas 2xx.
En-têtes :
- X-HSP-Event : checkout.payment.paid (ou .expired, .cancelled, .failed)
- X-HSP-Timestamp : Unix secondes
- X-HSP-Signature : HMAC-SHA256 du message timestamp + saut de ligne + corps brut reçu
Corps :
{
"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": "Commande 1001",
"checkout_url": "https://comptehsp.com/pay/checkout/…",
"expires_at": "…",
"paid_at": "…",
"created_at": "…",
"payer_label": "Awa D.",
"ledger_ref": "…",
"metadata": { "cart": "1001" },
"webhook_status": "PENDING"
}
}
Comparez la signature au corps brut (raw body), pas à un JSON ré-encodé. Refusez un horodatage de plus de 5 minutes. Vérifiez que payment.reference et payment.amount correspondent à votre commande, et que status vaut PAID, avant de livrer.
| Code | HTTP | Sens |
|---|---|---|
| missing_signature | 401 | En-tête manquant |
| invalid_signature | 401 | HMAC incorrect |
| stale_timestamp | 401 | Horloge hors fenêtre |
| replayed_signature | 401 | Signature déjà utilisée |
| unknown_api_key | 401 | key_id inconnu |
| revoked_api_key | 401 | Clé révoquée |
| already_paid | 409 | Référence déjà payée ou en cours |
| reference_conflict | 409 | Même référence, montant différent |
| payment_expired | 410 | Lien expiré |
| invalid_amount | 400 | Montant refusé |
| invalid_currency | 400 | Devise autre que EUR |
| invalid_url | 400 | URL non https ou non publique |
| invalid_pin | 400 | Téléphone ou PIN incorrect |
| insufficient_balance | 400 | Solde client insuffisant |
| too_many_attempts | 429 | Trop d’essais PIN |
Pour Node.js, PHP et Python : variables HSP_KEY (hspk_…) et HSP_SECRET (hsps_…). Sur WordPress, collez-les dans Réglages → 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}