Seev PlusDocs

Errors

Read a SeevPlus API error response, decide whether to retry it, and show the customer a safe message.

Every SeevPlus Checkout API failure returns a JSON body and an HTTP status. This page covers the response you actually get back, the failures each Checkout API route can produce, and what to do with each one. Amounts in every example are in the currency's smallest unit, so 10000 with currency GHS means GHS 100.00, matching the convention in the Checkout API guide.

Read an error response

A failed request returns the same two-field body on every route. Both fields carry the identical string, so read whichever one your client already parses.

{
    "error": "invalid API key; existing keys must be rotated once before using this endpoint",
    "message": "invalid API key; existing keys must be rotated once before using this endpoint"
}
FieldTypeRequiredDescription
errorstringAlways present on a failureHuman-readable failure description, intended for your logs. Not safe to show to a customer.
messagestringAlways present on a failureDuplicate of error. Present so clients written against either field keep working.

There is no machine-readable code field and no field-name pointer in this response, so branch on the HTTP status first and treat the text as a log detail rather than a stable contract. Do not pattern-match the message string in production code: it is a plain Go error string and it changes when the underlying wording changes.

Never render error or message to a customer. The text can name internal key state, organization state, or upstream gateway failures. Map the HTTP status to your own copy instead, as shown in Show the customer a safe message.

Gateway codes arrive inside the message, not as a field

When the failure happens at the payment gateway rather than at SeevPlus, the gateway's own response is folded into the message string rather than surfaced as structure. A gateway rejection reaches you looking like this, with its status code and body embedded as text:

{
    "error": "failed to initiate checkout: status code 422: {\"success\":false,\"error\":\"VALIDATION_ERROR\",\"message\":\"...\"}",
    "message": "failed to initiate checkout: status code 422: {\"success\":false,\"error\":\"VALIDATION_ERROR\",\"message\":\"...\"}"
}

The gateway does define codes of its own, including VALIDATION_ERROR, UNAUTHORIZED, NOT_FOUND and ALREADY_REFUNDED. They are not part of the SeevPlus contract and they reach you only as text inside that string, so log the message and branch on the HTTP status. Parsing the embedded JSON back out couples your integration to a wrapper format that is not versioned.

SeevPlus does not expose a machine-readable error code field today. The envelope is error and message, and the HTTP status is the stable part of the contract, so branch on the status and treat the message as text for logs and for a human.

Handle a failed checkout creation

POST https://api.seevplus.com/api/v1/developer/payments returns 201 when the session is created. Anything else is a failure, and the status tells you which layer rejected the request.

StatusCauseWhat to do
400The body is not valid JSON, is empty, is not a JSON object, or Idempotency-Key is longer than 128 characters. Also returned when the organization behind the key is not active.Fix the request and do not retry it unchanged. An inactive organization needs to be resolved in the Dashboard, not in code.
401The API key is missing, is not an active Checkout API key, or is not linked to an organization. Also returned when the key lookup itself fails.Check the key, its product, and its environment. Do not retry with the same key.
403The key has IP allowlisting enabled and the calling IP is not on the list.Add the caller's egress IP to the key's allowlist, or call from an allowlisted host.
502SeevPlus reached the payment gateway and the gateway did not return a usable checkout.Safe to retry with the same Idempotency-Key. Keep the order pending and do not tell the customer the payment failed.

Two of these deserve care. A key created before this route existed authenticates only after being rotated once, because SeevPlus stores a one-way hash of the secret and has no hash for older keys; that failure arrives as 401 with a message ending existing keys must be rotated once before using this endpoint. And an inactive organization returns 400 rather than 403, which reads like a malformed request but is not one, so log the response body on every 400 or you will chase a body-validation bug that does not exist.

Branch on the status, log the body, and keep the customer-facing copy generic.

type CheckoutResult =
    | { ok: true; checkoutUrl: string }
    | { ok: false; retryable: boolean };

async function createCheckout(orderId: string): Promise<CheckoutResult> {
    const response = await fetch(
        'https://api.seevplus.com/api/v1/developer/payments',
        {
            method: 'POST',
            headers: {
                Authorization: `Bearer ${process.env.SEEV_CHECKOUT_API_KEY}`,
                'Content-Type': 'application/json',
                // Derived from the order, so every retry of this order reuses it.
                'Idempotency-Key': `charge_order_${orderId}`,
            },
            body: JSON.stringify({
                type: 'checkout',
                amount: 10000, // smallest unit: GHS 100.00
                currency: 'GHS',
                recipient: { name: 'Jane Doe', email: 'jane@example.com' },
                redirect_url: 'https://yourapp.com/payment/callback',
                meta: { orderId },
            }),
        },
    );

    const payload = await response.json();

    // 201 is a new session; 200 is an idempotent replay of an existing one.
    if (response.status === 201 || response.status === 200) {
        return { ok: true, checkoutUrl: payload.data.checkout_url };
    }

    console.error('[Seev] Checkout creation failed', {
        orderId,
        status: response.status,
        body: payload,
    });

    // 502 means the gateway leg failed and the same key can be retried.
    return { ok: false, retryable: response.status === 502 };
}

A network-level failure never reaches this code as a status, because fetch rejects instead of resolving. Wrap the call at your job boundary and treat a rejection the same way you treat 502: the request may or may not have reached the gateway, so retry it with the same Idempotency-Key rather than a fresh one.

Recover from a duplicate or conflicting key

Idempotency responses come back from the gateway leg and are documented in full under Idempotency. In short, a repeat of an identical request returns 200 with the original payment, a request still in flight returns 202, and the same key sent with a different body returns 409. Treat 409 as a bug in your key derivation rather than something to retry: either resend the original body under that key, or pick a new key for the changed request.

Requesting checkout without an Idempotency-Key creates a new payment on every call, including on your retries. Derive the key from the order it protects before you send the first attempt.

Handle a failed session verification

GET https://api.seevplus.com/api/v1/developer/payments/{sessionRef} returns 200 with the current gateway session. This route takes no API key, so authentication failures do not apply to it.

StatusCauseWhat to do
400The session reference in the path is empty or blank after trimming.Check that you captured the reference from the redirect or from the creation response before calling.
404The gateway does not recognise the reference, or it is no longer valid.Do not fulfil the order. Confirm you are verifying in the environment the payment was created in.
502SeevPlus could not reach the gateway or the gateway returned an unusable session.Retry with backoff. This says nothing about whether the customer paid, so leave the order pending.

A 200 here is not by itself proof of payment. Read data.status and fulfil only on completed or success, treating pending, failed, and cancelled as unfulfilled.

async function verifyCheckout(sessionRef: string) {
    const response = await fetch(
        `https://api.seevplus.com/api/v1/developer/payments/${encodeURIComponent(sessionRef)}`,
    );
    const payload = await response.json();

    if (!response.ok) {
        console.error('[Seev] Verification failed', {
            sessionRef,
            status: response.status,
            body: payload,
        });
        // 502 is transient; 400 and 404 are not.
        return { paid: false, retryable: response.status === 502 };
    }

    const status = payload.data.status;
    return {
        paid: status === 'completed' || status === 'success',
        retryable: status === 'pending',
    };
}

Decide whether to retry

Retry only the statuses that describe a failure in transit rather than a failure in your request.

StatusRetryWhy
400, 401, 403, 404NoThe same request produces the same result. Fix the request, the key, or the resource reference.
409NoThe idempotency key is already bound to a different body. Change the key or the body first.
502Yes, with the same keyThe gateway leg failed and the outcome is unknown.
Network rejectionYes, with the same keyThe request may have been processed. Reusing the key is what prevents a second charge.

Back off between attempts rather than retrying immediately, and cap the attempts so a stuck order surfaces to a human instead of looping. Reuse the Idempotency-Key across every attempt of the same logical payment, because that reuse, and not the backoff, is what makes the retry safe.

SeevPlus applies no rate limit of its own to the developer Checkout API routes, so there is no 429 and no Retry-After header to handle from api.seevplus.com. The upstream payment gateway does enforce one, and a breach there arrives the same way any other upstream failure does: a 502 whose message carries the upstream text. Back off and retry with the same Idempotency-Key.

Verify a webhook before acting on it

SeevPlus signs each delivery and sends it as POST with these headers.

HeaderTypeRequiredDescription
X-Seev-Event-IDstringAlways sentEvent log identifier. Use it as your deduplication key.
X-Seev-Event-TypestringAlways sentEither payment.succeeded or payment.failed.
X-Seev-TimestampstringAlways sentUnix seconds at signing time, and the first input to the signature.
X-Seev-SignaturestringAlways sentv1= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the webhook's signing secret.

Compute the signature over the raw request body, before any JSON parsing reserialises it, and compare in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto';

function isValidSeevSignature(
    rawBody: Buffer,
    timestamp: string,
    header: string,
    signingSecret: string,
): boolean {
    const expected =
        'v1=' +
        createHmac('sha256', signingSecret)
            .update(timestamp)
            .update('.')
            .update(rawBody)
            .digest('hex');

    const a = Buffer.from(header);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
}

If verification fails, return a non-2xx response and apply no side effects. SeevPlus records any non-2xx as a failed delivery, which is what makes a tampered or misrouted event visible in the Dashboard.

Work around single-attempt delivery

Automatic webhook delivery is one attempt. SeevPlus posts the event with an 8 second timeout and marks the delivery failed if your endpoint times out, refuses the connection, or answers with any non-2xx status. There is no automatic redelivery and no backoff schedule; a failed event is replayed only when someone retries it explicitly from the Dashboard or the webhook retry endpoint.

Two consequences follow, and both need handling in your integration.

  • Webhooks are not a reliable primary signal on their own. Verify the session server-side before fulfilling, and treat the webhook as the prompt to verify rather than as the proof.
  • Deliveries for one organization are dispatched concurrently, one goroutine per subscribed endpoint, so events can arrive out of order. Order your own processing on the transaction status you read back at verification time, never on arrival order.

Deduplicate on X-Seev-Event-ID, since an operator-triggered retry delivers the same event again. Read Webhooks for endpoint setup and the retry controls.

Show the customer a safe message

Map the HTTP status to your own copy and keep the API text in your logs. The mapping below is a starting point rather than a fixed contract, because the customer-facing wording is yours to own.

function customerMessage(status: number): string {
    switch (status) {
        case 400:
        case 401:
        case 403:
            // Your integration is misconfigured. Do not blame the customer.
            return 'We could not start this payment. Please contact support.';
        case 404:
            return 'This payment session is no longer available. Please start again.';
        case 502:
            return 'We could not reach our payment provider. Please try again shortly.';
        default:
            return 'Something went wrong. Please try again or contact support.';
    }
}

Log the HTTP status, the full response body, the order ID, and the Idempotency-Key you sent on every failure. Those four together are what lets you match a customer complaint to a request without asking them to reproduce it. Never write a secret key or a full card or customer payment payload into those logs, and never paste one into a support ticket.

On this page