How it works
Every Veriss payment service can have an API payment link. Your website posts to it, and the customer finishes paying on Veriss. You never handle card details.
Your website
Create a checkout
POST the order (amount, reference, customer) to the API payment link.
Customer
Pays on Veriss
Sees your organization, the service and the amount, then pays with Paystack.
Veriss
Notifies you
Sends a signed payment.completed webhook to your server.
Customer
Returns to you
Back on your callback_url with a signed status. You confirm and deliver.
All amounts are whole numbers in kobo, the smallest unit of the naira: 500000 = ₦5,000.
This is the same convention Paystack uses.
Quick start
- Sign in to your organization dashboard and open Developers.
- Create a secret key and store it on your server (e.g.
VERISS_SECRET_KEY). - Add your return domain (e.g.
shop.example.com) and a webhook URL. - Edit a service, turn on API checkout, and copy its API payment link.
Then create a checkout from your server and redirect the customer to checkout_url:
const crypto = require('crypto');
const SECRET = process.env.VERISS_SECRET_KEY; // vrs_sk_...
const serviceId = 'YOUR_SERVICE_ID';
const amount = 500000; // kobo (₦5,000). Leave out for fixed-price services.
const reference = 'ORDER-1001'; // your order id: each one can be paid only once
const signature = crypto.createHmac('sha256', SECRET)
.update([serviceId, amount, reference].join('|'))
.digest('hex');
const res = await fetch(`{{BASE}}/${serviceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount, reference, signature,
name: 'Ada Obi',
email: 'ada@example.com',
callback_url: 'https://shop.example.com/payment/return',
metadata: { cartId: 'c_123' },
}),
});
const { data } = await res.json();
// Send the customer to the hosted checkout page
response.redirect(303, data.checkout_url);
<?php
$secret = getenv('VERISS_SECRET_KEY'); // vrs_sk_...
$serviceId = 'YOUR_SERVICE_ID';
$amount = 500000; // kobo (₦5,000); use '' for fixed-price services
$reference = 'ORDER-1001';
$signature = hash_hmac('sha256', "$serviceId|$amount|$reference", $secret);
$ch = curl_init("{{BASE}}/$serviceId");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'amount' => $amount, 'reference' => $reference, 'signature' => $signature,
'name' => 'Ada Obi', 'email' => 'ada@example.com',
'callback_url' => 'https://shop.example.com/payment/return',
]),
]);
$result = json_decode(curl_exec($ch), true);
header('Location: ' . $result['data']['checkout_url'], true, 303);
import hmac, hashlib, os, requests
SECRET = os.environ["VERISS_SECRET_KEY"] # vrs_sk_...
service_id = "YOUR_SERVICE_ID"
amount = 500000 # kobo (₦5,000); use "" for fixed-price services
reference = "ORDER-1001"
payload = f"{service_id}|{amount}|{reference}".encode()
signature = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
res = requests.post(f"{{BASE}}/{service_id}", json={
"amount": amount, "reference": reference, "signature": signature,
"name": "Ada Obi", "email": "ada@example.com",
"callback_url": "https://shop.example.com/payment/return",
})
checkout_url = res.json()["data"]["checkout_url"]
# return redirect(checkout_url, code=303)
# signature = HMAC-SHA256 of "YOUR_SERVICE_ID|500000|ORDER-1001" with your secret key
curl -X POST "{{BASE}}/YOUR_SERVICE_ID" \
-H "Content-Type: application/json" \
-d '{
"amount": 500000,
"reference": "ORDER-1001",
"email": "ada@example.com",
"callback_url": "https://shop.example.com/payment/return",
"signature": "<hex signature>"
}'
No server-side redirect? Use an HTML form
The API payment link also accepts a plain form post and redirects the customer straight to the checkout page. Your server still fills in the signature when it renders the form.
<form action="{{BASE}}/YOUR_SERVICE_ID" method="POST">
<input type="hidden" name="amount" value="500000">
<input type="hidden" name="reference" value="ORDER-1001">
<input type="hidden" name="name" value="Ada Obi">
<input type="hidden" name="email" value="ada@example.com">
<input type="hidden" name="callback_url" value="https://shop.example.com/payment/return">
<input type="hidden" name="signature" value="<?= $signature ?>">
<button type="submit">Pay with Veriss</button>
</form>
Keys & settings
Everything is configured by the organization in Dashboard → Developers.
| Setting | What it does |
|---|---|
| Secret key | Starts with vrs_sk_. Signs your requests, and lets you check return URLs and webhooks and call the verify API. Shown once when created. Roll key replaces it, and the old key stops working at once. |
| Allowed return domains | Hosts that callback_url may use (subdomains included), over https. http://localhost is allowed for testing. |
| Default webhook URL | An https endpoint on your server that receives payment.completed. Use Send test event to try it. |
| Webhook URL per service | Optional, in the service's API checkout settings. That service's events go there instead of the default, so different systems can each get their own. |
| API checkout (per service) | Gives the service an API payment link. Only accept signed requests is on by default. |
Keep the secret key on your server. Never put it in browser JavaScript, mobile apps or a public repository.
Create a checkout
Send application/json from your server to get JSON back, or post an HTML form
(application/x-www-form-urlencoded) to be redirected (303) to the checkout page.
| Field | Description | |
|---|---|---|
amount | Depends | Kobo, a whole number of at least 5000 (₦50). For fixed-price services leave it out or send exactly the price. For flexible services leave it out to let the customer enter the amount. |
reference | Recommended | Your order id: letters, numbers and . _ : / -, up to 100 characters. A paid reference can't be paid again. |
name, email, phone_number | Optional | Pre-fill the customer's details on the checkout page. |
callback_url | Optional | Where to send the customer afterwards. Must be on an allowed domain over https. |
metadata | Optional | A JSON object (a JSON string in forms), up to 2,000 bytes. Returned by the verify API and in webhooks. |
signature | If required | See Signing requests. |
{
"success": true,
"data": {
"session_id": "cs_9fJxQ2...",
"checkout_url": "{{ORIGIN}}/checkout/cs_9fJxQ2...",
"expires_at": "2026-10-12T10:52:01.000Z",
"amount": 500000,
"currency": "NGN",
"merchant_reference": "ORDER-1001",
"service": { "id": "YOUR_SERVICE_ID", "name": "Annual Dues" }
}
}
- A checkout link works for 1 hour.
- Sending the same unpaid
referenceagain returns the existing checkout (200), so a double click doesn't create two. - The service's access rules still apply to the customer's email, so services open to the public work best (Public only or Subscribers and the public).
Amounts
| Service price | What to send | Customer sees |
|---|---|---|
| Fixed | Nothing, or exactly the price | The fixed price |
| Flexible, amount from you | amount (signed) | Your amount, locked |
| Flexible, customer chooses | No amount | An amount field with the minimum shown |
| Closed fundraiser | Optional amount | Up to what's left to reach the target |
The minimum is ₦50 (5000 kobo), the smallest charge Paystack accepts. Processing fees are added
on top on the payment page. The organization receives the full amount.
Signing requests
A signature proves the request came from your server and that nobody changed the amount or reference in the browser.
Use an empty string for anything you don't send. A fixed-price service with no amount signs "SERVICE_ID||ORDER-1001".
const signature = crypto.createHmac('sha256', process.env.VERISS_SECRET_KEY)
.update([serviceId, amount ?? '', reference ?? ''].join('|'))
.digest('hex');
$signature = hash_hmac('sha256', "$serviceId|$amount|$reference", getenv('VERISS_SECRET_KEY'));
signature = hmac.new(SECRET.encode(), f"{service_id}|{amount}|{reference}".encode(), hashlib.sha256).hexdigest()
If an organization turns off Only accept signed requests, a customer can edit the amount in your form. That's safe for fixed prices but not for flexible ones, so always check the amount with the verify API before you deliver.
Returning to your site
When the checkout ends, the customer is sent to your callback_url with these parameters added:
| Parameter | Value |
|---|---|
status | success, cancelled or expired |
session_id | The checkout session |
reference | The Veriss payment reference (VRS-…); empty if nothing was paid |
merchant_reference | Your reference |
amount | Kobo paid for the service (fees excluded) |
signature | HMAC-SHA256 of session_id|status|reference|merchant_reference|amount |
app.get('/payment/return', async (req, res) => {
const { status, session_id, reference, merchant_reference, amount, signature } = req.query;
const expected = crypto.createHmac('sha256', process.env.VERISS_SECRET_KEY)
.update([session_id, status, reference, merchant_reference, amount].join('|'))
.digest('hex');
if (!signature || signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(400).send('Invalid signature');
}
if (status !== 'success') return res.redirect('/cart?payment=' + status);
// Confirm with Veriss before delivering the order (see "Verify a payment")
const order = await confirmAndFulfil(merchant_reference, reference);
res.redirect('/orders/' + order.id);
});
The return is a convenience, not proof of payment: the customer controls the browser and might not come back. Rely on the webhook or the verify API.
Verify a payment
Authenticate with Authorization: Bearer vrs_sk_…. Deliver the order only when status is
success and merchant_reference and amount match it.
const r = await fetch(`{{BASE}}/transactions/${encodeURIComponent(reference)}`, {
headers: { Authorization: `Bearer ${process.env.VERISS_SECRET_KEY}` },
});
const { data } = await r.json();
if (data.status === 'success' && data.merchant_reference === order.id && data.amount === order.amountKobo) {
await markOrderPaid(order, data.reference);
}
curl "{{BASE}}/transactions/VRS-1791795121727-448" \
-H "Authorization: Bearer $VERISS_SECRET_KEY"
{
"success": true,
"data": {
"reference": "VRS-1791795121727-448",
"status": "success",
"amount": 500000,
"amount_charged": 530458,
"fees": 30458,
"currency": "NGN",
"paid_at": "2026-10-12T08:52:01.793Z",
"session_id": "cs_75eMdP2o9QwhJwoiQwsIG4u3",
"merchant_reference": "ORDER-1001",
"metadata": { "cartId": "c_123" },
"payer": { "name": "Ada Obi", "email": "ada@example.com", "phone_number": null },
"service": { "id": "YOUR_SERVICE_ID", "name": "Annual Dues" },
"source": "api"
}
}
statusispending,processingorsuccess.amountis what the organization receives.amount_chargedis what the customer paid, including fees.
Find an order
Returns the checkout for one of your orders: the paid one if there is one, otherwise the latest. Use it when a customer never came back and no webhook arrived.
{
"success": true,
"data": {
"session_id": "cs_75eMdP2o9QwhJwoiQwsIG4u3",
"status": "paid", // open | processing | paid | cancelled | expired
"merchant_reference": "ORDER-1001",
"amount": 500000,
"created_at": "2026-10-12T08:51:40.112Z",
"transaction": { "reference": "VRS-...", "status": "success", ... }
}
}
Webhooks
As soon as a payment succeeds, Veriss sends POST to your webhook URL. This happens even if the customer
closes their browser.
| Header | Value |
|---|---|
X-Veriss-Event | payment.completed (or ping for test events) |
X-Veriss-Signature | Hex HMAC-SHA256 of the raw request body with your secret key |
app.post('/webhooks/veriss', express.raw({ type: 'application/json' }), async (req, res) => {
const expected = crypto.createHmac('sha256', process.env.VERISS_SECRET_KEY)
.update(req.body) // raw bytes, before JSON.parse
.digest('hex');
const received = String(req.get('X-Veriss-Signature') || '');
if (received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body);
if (event === 'payment.completed') {
await markOrderPaid(data.merchant_reference, data); // idempotent: it can arrive more than once
}
res.sendStatus(200);
});
<?php
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, getenv('VERISS_SECRET_KEY'));
if (!hash_equals($expected, $_SERVER['HTTP_X_VERISS_SIGNATURE'] ?? '')) {
http_response_code(401); exit;
}
$event = json_decode($body, true);
if ($event['event'] === 'payment.completed') {
markOrderPaid($event['data']['merchant_reference'], $event['data']); // idempotent
}
http_response_code(200);
{
"event": "payment.completed",
"data": {
"reference": "VRS-1791795121727-448",
"status": "success",
"amount": 500000,
"amount_charged": 530458,
"merchant_reference": "ORDER-1001",
"metadata": { "cartId": "c_123" },
"...": "same fields as the verify API",
"duplicate": false
},
"sentAt": "2026-10-12T08:52:02.010Z"
}
- Reply with any
2xxwithin 10 seconds. Redirects aren't followed. - Failed deliveries are retried after 1, 5 and 30 minutes.
duplicate: truemeans the customer paid a checkout that was already paid (e.g. in two tabs). Consider refunding it.
Errors
Errors are JSON: { "success": false, "code": "…", "message": "…" }. A rejected HTML form post shows a
Veriss error page with the same message instead.
| Code | HTTP | Meaning |
|---|---|---|
signature_required | 401 | The service only accepts signed requests |
invalid_signature | 401 | The signature doesn't match the amount, reference and key |
invalid_amount | 400 | Not whole kobo, below ₦50, or above a fundraiser's remaining target |
amount_mismatch | 400 | Amount sent for a fixed-price service doesn't equal the price |
callback_url_not_allowed | 400 | The return domain isn't on your allowed list |
invalid_callback_url | 400 | Not a full https URL |
invalid_reference, invalid_email, invalid_phone, invalid_metadata | 400 | Field format |
already_paid | 409 | This reference was already paid; data.reference is the payment |
service_inactive | 409 | The service is suspended, ended or not started yet |
api_checkout_disabled | 403 | API checkout is off for this service |
api_not_configured | 403 | The organization hasn't created a secret key |
service_not_found | 404 | Unknown service id |
unauthorized | 401 | Missing or wrong secret key (verify and lookup) |
| — | 429 | Too many requests; wait for Retry-After seconds |
Go-live checklist
- The secret key is in a server environment variable, not in code or the browser.
- Requests are signed, and Only accept signed requests is on for the service.
- Amounts are sent in kobo (₦5,000 =
500000). - Every order has a unique
reference. - Your return page checks the signature, then confirms with the verify API.
- Your webhook endpoint checks
X-Veriss-Signatureand handles repeats safely. Send test event succeeds. - You compare
amountandmerchant_referencewith your order before delivering. - You've made one real payment end to end and checked it in Developers → Recent API checkouts.
Security notes
- Signing needs a server. A purely static website can't keep a secret key. Use a small serverless function to sign, or accept unsigned requests and always verify the amount.
- Roll your key right away if you think it has leaked. The old key stops working immediately, so update your servers at the same time.
- Double payments are possible. A customer who pays the same checkout twice (two tabs) is charged twice. Both payments are reported, the second with
duplicate: true. - The webhook can be late or arrive twice. If it hasn't arrived, use lookup or verify, which are always up to date.
FAQ
Do customers need a Veriss account?
No. They enter their name and email on the checkout page and get a receipt.
Who pays the fees?
The customer. Processing and platform fees are added on top of the amount, so the organization receives the full amount.
Can I test without real money?
Use a low-priced test service and a small amount (₦50 is the minimum), or a separate test environment run with Paystack test keys.
What does the customer see?
Your organization's name (with its verified check mark, if verified), the service, the amount, your order reference and a Cancel and return link back to your site.
Ready to integrate?
Sign in to create your secret key and turn on API checkout for a service.