HSP Wallet Merchant payment API
Merchant payment API

HSP Payment API — collect from another website

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.

Base URL https://comptehsp.comEURHMAC-SHA256
  1. 1Key in the desk
  2. 2Checkout link
  3. 3Signed webhook
Merchant desk

1. Get the API key (one per merchant)

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

2. HMAC signature on every call

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

3. Create a payment

POST /api/v1/checkout/payments

FieldRequiredDescription
referenceyesYour order id, unique. 1–80 characters: letters, digits, . _ : -
amountyesAmount in EUR, from 0.01 to 50000. JSON number, not a string.
currencyyesEUR only
return_urlyesPublic https URL where the customer returns after payment
cancel_urlnoPublic https URL if the customer cancels
webhook_urlnoPublic https URL that receives the result
descriptionnoLabel shown to the customer, 180 characters max
expires_innoLink lifetime in seconds, 60 to 86400. Default: 1800 (30 min)
localenoCheckout page language: fr, en, es, de, pt, ar
customer.emailnoStored for your records
customer.namenoFree name, 120 characters max
metadatanoFree 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.

4. Duplicate protection, expiry, statuses

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.

5. Check the status

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.

6. Webhook

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.

7. Error codes

CodeHTTPMeaning
missing_signature401Header missing
invalid_signature401Bad HMAC
stale_timestamp401Clock outside the window
replayed_signature401Signature already used
unknown_api_key401Unknown key_id
revoked_api_key401Key revoked
already_paid409Reference already paid or in progress
reference_conflict409Same reference, different amount
payment_expired410Link expired
invalid_amount400Amount rejected
invalid_currency400Currency is not EUR
invalid_url400URL is not public https
invalid_pin400Phone or PIN is wrong
insufficient_balance400Customer balance too low
too_many_attempts429Too many PIN attempts

Examples

For Node.js, PHP and Python, set HSP_KEY (hspk_…) and HSP_SECRET (hsps_…). On WordPress, paste them in Settings → HSP Paiement.

Create a payment

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);
});

Check the status

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()));

Verify the webhook

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 });
});