Developer Webhooks
Receive, verify, and safely process the signed payment events SeevPlus sends to your server.
Developer webhooks tell your server when a payment created through the Seev API reaches a final status, so you can fulfil an order or update your records without polling. They require developer access and a webhook endpoint configured for the environment you are working in. Creating keys is covered in API Keys.
SeevPlus sends these events itself. It first consumes and processes the underlying gateway event, then emits a stable merchant-facing event to your endpoint. A browser redirect back to your site is a user-experience signal and never proof of payment, so fulfil against a verified webhook or a server-side verification call.
Create an endpoint
Open Seev API -> Webhooks and create an endpoint for the environment you want. Sandbox and production endpoints are configured separately and never receive each other's events.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Absolute URL including a host. http and https schemes are both accepted; use https for anything carrying real payments. |
env | string | Yes | sandbox or production. Must match an environment your developer profile has enabled. |
events | array of strings | Yes | At least one event name. Duplicates are collapsed and the list is stored sorted. An unrecognized name is rejected. |
The response returns the endpoint plus its signing secret, which is the only time the secret is shown:
{
"id": "68c1f4a1c3b5d2a91f0e7742",
"url": "https://api.example.com/webhooks/seev",
"events": ["payment.failed", "payment.succeeded"],
"env": "production",
"status": "active",
"signingSecret": "3Qk9vT2mYb1sJ7pR0aZ4xLd8HcN6fUeW2gK5nB1oQ3s"
}The signing secret is 32 random bytes in base64url form with no prefix. Store it in the same secrets manager that holds your API keys. It is unique to this endpoint, it never belongs in browser or mobile code, and it is the only thing that distinguishes a genuine Seev request from a forged one.
Your endpoint must be reachable from the public internet. For local development, expose it through a tunnel such as ngrok or Cloudflare Tunnel and register the tunnel URL.
Subscribe to the right events
| Event | Type | Required | Description |
|---|---|---|---|
payment.succeeded | string | No | The transaction moved to completed. Emitted from the gateway statuses success, successful, succeeded, complete, and completed. |
payment.failed | string | No | The transaction moved to failed or cancelled. Emitted from the gateway statuses failed, failure, declined, cancelled, and canceled. |
A transaction sitting at pending emits nothing. There is no payment.pending event, so a customer who abandons checkout produces no delivery at all, and your reconciliation has to notice the silence rather than wait for a message.
Subscribe to both events unless you genuinely handle only one. The failure handler is what keeps an order unpaid and offers the customer a retry instead of leaving it stuck.
Read the payload
Every delivery is a POST with a JSON body in this envelope:
{
"id": "68c1f52ac3b5d2a91f0e7751",
"event": "payment.succeeded",
"env": "production",
"createdAt": "2026-07-18T21:09:12Z",
"data": {
"transaction": {
"id": "68c1f4d9c3b5d2a91f0e7748",
"env": "production",
"product": "checkout",
"type": "payment",
"amount": 100,
"currency": "GHS",
"status": "completed",
"reference": "PAY-20260718-f23159df-9df8-4e69-8306-c11d71ead351",
"gatewaySessionRef": "PAY-20260718-f23159df-9df8-4e69-8306-c11d71ead351",
"checkoutUrl": "https://pay.seevplus.com/PAY-20260718-f23159df-9df8-4e69-8306-c11d71ead351",
"createdAt": "2026-07-18T21:08:46Z",
"updatedAt": "2026-07-18T21:09:12Z"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Delivery event ID. Matches the X-Seev-Event-ID header. Changes on every manual retry, so it is not a stable transaction identifier. |
event | string | Yes | payment.succeeded or payment.failed. |
env | string | Yes | sandbox or production. Reject an event whose environment does not match the handler that received it. |
createdAt | string | Yes | RFC 3339 UTC timestamp of when the event record was created. |
data.transaction.amount | number | Yes | The transaction amount in the currency's major unit. The Checkout API takes and returns amounts in the smallest unit, and this field is that value divided by 100, so a session created with amount: 10000 arrives here as 100 and means GHS 100.00. The divisor is fixed at 100 for every currency. Every currency the API accepts divides into hundredths, so that conversion always holds. |
data.transaction.currency | string | Yes | Uppercase currency code, for example GHS. |
data.transaction.reference | string | Yes | The Seev payment reference. Stable across retries and duplicate deliveries. Use this, not id, as your deduplication key. |
data.transaction.status | string | Yes | completed, failed, cancelled, or pending. |
data.transaction.metadata | object | No | Includes your own meta object under developer, plus customer name, email, and phone as reported by the gateway. |
The amount in a webhook is in major units while the Checkout API request and response are in the smallest unit. Compare a webhook amount against your stored order total only after converting one of the two.
Verify the signature
Every request carries four headers:
| Header | Type | Required | Description |
|---|---|---|---|
X-Seev-Event-ID | string | Yes | Delivery event ID, unique per delivery attempt record. |
X-Seev-Event-Type | string | Yes | Event name, for example payment.succeeded. |
X-Seev-Timestamp | string | Yes | Unix seconds at signing time. Part of the signed material. |
X-Seev-Signature | string | Yes | v1= followed by the lowercase hex HMAC digest. |
The signature covers the timestamp and the exact bytes of the body:
"v1=" + hex(HMAC_SHA256(signingSecret, timestamp + "." + rawBody))Verify against the raw body before you parse it. Re-serializing the JSON changes the bytes and the digest will not match.
import crypto from 'crypto';
export function verifySeevWebhook({
rawBody,
timestamp,
signature,
secret,
}: {
rawBody: string;
timestamp: string;
signature: string;
secret: string;
}) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(Number(timestamp)) || age > 300) {
return false;
}
const expected =
'v1=' +
crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected);
const actualBuf = Buffer.from(signature || '');
if (expectedBuf.length !== actualBuf.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}timingSafeEqual throws when the two buffers differ in length, which is exactly what a garbage or missing signature header produces, so compare lengths first and return false rather than letting the exception become a 500. A 500 is recorded as a failed delivery and gives an attacker a way to tell malformed signatures apart from wrong ones.
The 300-second window above is your choice, not a Seev-enforced limit. Seev signs with the timestamp at send time and does not reject anything on age; rejecting old timestamps is what stops a captured request being replayed at you later. Keep the window wide enough to survive clock skew on your own host.
When verification fails
| Symptom | Cause | What to do |
|---|---|---|
| Digest never matches, headers look correct | The body was parsed and re-serialized before hashing. | Capture the raw body. In Express, express.raw({ type: "application/json" }) on the webhook route only. In Next.js route handlers, await req.text() before any req.json(). |
| Digest never matches on one endpoint only | The wrong signing secret is in use. Secrets are per endpoint, not per organization. | Match the secret to the endpoint ID that received the delivery, and remember that deleting and recreating an endpoint issues a new secret. |
timingSafeEqual throws | The header is absent or a different length than the expected digest. | Compare lengths first, as above. |
| Verification passes but the event is for the wrong environment | Sandbox and production endpoints both point at one handler. | Check env in the payload, or run separate handler URLs per environment. |
Return 401 for a request you cannot verify, and do not apply any side effect. That failure appears in the webhook logs with HTTP status 401, which is what you want when you are diagnosing a secret mismatch.
Handle retries, duplicates, and ordering
This is the part most integrations get wrong, so the behaviour is worth stating exactly.
One automatic attempt. SeevPlus sends each event once. There is no automatic retry schedule and no backoff. The delivery is given 8 seconds; if your endpoint has not responded by then, the attempt is cancelled and recorded as failed with the error text from the timeout. Any 2xx marks the delivery delivered; anything else, along with connection failures, marks it failed.
A failed delivery is not retried for you. If your endpoint is down for a
deploy, those events are only recoverable by replaying them by hand from
Webhook logs. Return 2xx as soon as the event is durably recorded, and
do the slow work afterwards.
Retries are manual and change the event ID. You can replay any delivery from the dashboard, including one that already succeeded. A replay writes a new event log with the same payload body but a fresh event ID, in both X-Seev-Event-ID and the payload's id, linked back to the original through retriedFromEventId. Deduplicating on the event ID therefore does not protect you from a manual replay. Deduplicate on data.transaction.reference together with the status you applied.
Duplicates arrive from three directions. A manual replay is one. A second is having several endpoints subscribed to the same event in the same environment, each of which receives its own delivery. A third is a transaction that changes status more than once, which emits a separate event per transition.
Ordering is not guaranteed. Deliveries are dispatched concurrently, one goroutine per subscribed endpoint, and nothing serializes them or waits for the previous attempt. Two events for the same transaction can arrive out of order or overlap. Treat data.transaction.status as the authority for the state at the moment of the event, use updatedAt to discard an event older than what you have already applied, and never derive state from arrival order.
Events fire on transition, not on inspection. SeevPlus emits an event only when a transaction's stored status actually changes, so calling the verify endpoint repeatedly against an unchanged transaction produces nothing. The flip side is that your own verify call is one of the things that can discover a status change, which means a GET from your server can be what triggers the webhook you then receive.
A handler that survives all of the above:
export async function POST(req: Request) {
const rawBody = await req.text();
const valid = verifySeevWebhook({
rawBody,
timestamp: req.headers.get('X-Seev-Timestamp') || '',
signature: req.headers.get('X-Seev-Signature') || '',
secret: process.env.SEEV_WEBHOOK_SECRET!,
});
if (!valid) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
const tx = event.data?.transaction;
// Deduplicate on the payment reference, which survives manual replays,
// and ignore an event older than the state already recorded.
const applied = await recordEventIfNewer({
reference: tx.reference,
status: tx.status,
updatedAt: tx.updatedAt,
});
if (applied) {
await enqueueFulfilment(tx.reference); // slow work happens off the request
}
return Response.json({ received: true });
}Inspect a delivery
Webhook logs records every attempt, successful or not.
| Field | Type | Required | Description |
|---|---|---|---|
| URL | string | Yes | The endpoint the event was sent to. |
| Event type | string | Yes | payment.succeeded or payment.failed. |
| Status | string | Yes | pending, delivered, or failed. |
| HTTP status | number | No | The status code your server returned. Absent when the connection failed or timed out before a response. |
| Response body | string | No | What your server returned, truncated at 4096 bytes. |
| Error | string | No | Transport error text, or webhook returned status <code> for a non-2xx response. |
| Date | string | Yes | When the attempt was made. |
The stored response body is truncated at 4096 bytes, so a long error page from your server is cut off in the log. Read these together with the corresponding row in Developer Transactions when a payment and your records disagree.
| Log symptom | Cause | What to do |
|---|---|---|
failed with no HTTP status | DNS failure, TLS failure, refused connection, or the 8-second timeout elapsed. | Confirm the URL resolves publicly and responds inside 8 seconds. A tunnel that has expired since you registered it produces this. |
failed with HTTP 401 | Your handler rejected the signature. | Work through the verification failures above. |
failed with HTTP 404 or 405 | The route does not exist, or does not accept POST. | Endpoints must accept POST at the exact registered path. |
failed with HTTP 5xx | Your handler threw, often while doing fulfilment work inline. | Acknowledge first, process afterwards, then replay the failed deliveries from the log. |
delivered but nothing happened in your system | Your handler returned 2xx before completing, or deduplication discarded the event. | Check whether a prior delivery for the same reference already marked it processed. |
| No log row at all | No active endpoint in that environment subscribes to that event, or the transaction never left pending. | Check the endpoint's environment, status, and event list against the transaction. |