Checkout API
Create a checkout session from your backend, redirect the payer, and verify the result before you fulfil the order.
POST /api/v1/developer/payments
Requires developer access and an API keyUse the Checkout API when your own application needs to start the payment, rather than a Dashboard tool. Your backend creates a checkout session through SeevPlus, which records the organization and environment against the transaction before the payer is sent to the hosted checkout page. If you only need to send someone a link or sell a product without writing code, use Payment Links, Invoices or the Storefront instead.
To get access, open the Developer Dashboard, accept the developer terms, select Checkout API as a product, and generate a key. The API Keys guide walks through that. If your integration still calls gateway-api.seevcash.com directly, follow Migrate to the SeevPlus Payment Routes first, because a direct gateway call cannot be attributed to an organization.
Trace one payment end to end
Your server creates a checkout session with the customer and order details.
Seev returns a checkout URL. Redirect the customer there.
The customer pays with a method enabled on the hosted checkout page.
Seev redirects the customer back to your
redirect_urlwith a session reference.Your server verifies the session before fulfilling the order.
The redirect to your redirect_url is a hint, not proof. Customers close
tabs, networks drop, and anyone who knows the URL can request it. Fulfil
only after step 5, a verified status from Seev.
Create sessions from a backend or server function and keep the secret key in a server-side environment variable, because the key authorizes payment creation for your organization. Save your own order record before you request a session, so that you have something to reconcile the returned reference against.
Authenticate a request
Both routes live on one host. Sandbox and production are selected by the key you send, not by a different base URL, so a sample on this page runs against sandbox when you export a sandbox key into SEEV_CHECKOUT_API_KEY.
| Action | Method | Route |
|---|---|---|
| Create checkout session | POST | https://api.seevplus.com/api/v1/developer/payments |
| Verify checkout session | GET | https://api.seevplus.com/api/v1/developer/payments/{sessionRef} |
Bearer <secret key>. Interchangeable with X-API-Key, which Seev
reads first.
Any string up to 128 characters, one per logical order. Strongly recommended.
application/json.
Send the secret as Authorization: Bearer $SEEV_CHECKOUT_API_KEY, or as X-API-Key: $SEEV_CHECKOUT_API_KEY if that suits your HTTP client better. The verify route takes no credentials at all. If you generate keys for several products, use the one created for Checkout API, since product keys are scoped to their own Seev services.
SeevPlus stores only a one-way hash of newly issued checkout secrets and never persists the plaintext. A checkout key created before this route existed has no stored hash to match, so rotate it once before it can authenticate here.
Official SDKs, browser packages, mobile SDKs, and platform plugins such as WordPress or WooCommerce are not available yet. Call the REST API from your backend.
Create a checkout session
Amounts and item prices are integers in the currency's smallest unit, so 10000 means GHS 100.00 and the 800 below means GHS 8.00. Send either amount or items; the gateway calculates the final amount either way.
curl -X POST "https://api.seevplus.com/api/v1/developer/payments" \
-H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" \
-H "Idempotency-Key: order_123_attempt_1" \
-H "Content-Type: application/json" \
-d '{
"type": "checkout",
"recipient": {
"name": "Kwame Asante",
"email": "kwame@example.com"
},
"items": [
{
"name": "Web Development",
"description": "Landing page design",
"quantity": 1,
"price": 800,
"image": "https://example.com/web-development.png"
},
{
"name": "Hosting (1 year)",
"description": "Cloud hosting plan",
"quantity": 1,
"price": 200,
"image": ""
}
],
"currency": "GHS",
"channels": ["mobile_money"],
"redirect_url": "https://yourapp.com/payment/callback",
"meta": {
"orderId": "order_123"
}
}'const response = await fetch(
'https://api.seevplus.com/api/v1/developer/payments',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SEEV_CHECKOUT_API_KEY}`,
'Idempotency-Key': 'order_123_attempt_1',
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'checkout',
recipient: { name: 'Kwame Asante', email: 'kwame@example.com' },
items: [
{
name: 'Web Development',
description: 'Landing page design',
quantity: 1,
price: 800, // GHS 8.00
image: 'https://example.com/web-development.png',
},
{
name: 'Hosting (1 year)',
description: 'Cloud hosting plan',
quantity: 1,
price: 200, // GHS 2.00
image: '',
},
],
currency: 'GHS',
channels: ['mobile_money'],
redirect_url: 'https://yourapp.com/payment/callback',
meta: { orderId: 'order_123' },
}),
},
);
const payload = await response.json();
if (response.status !== 201) {
throw new Error(payload.error ?? 'checkout creation failed');
}
console.log(payload.data.checkout_url);import os
import requests
response = requests.post(
"https://api.seevplus.com/api/v1/developer/payments",
headers={
"Authorization": f"Bearer {os.environ['SEEV_CHECKOUT_API_KEY']}",
"Idempotency-Key": "order_123_attempt_1",
"Content-Type": "application/json",
},
json={
"type": "checkout",
"recipient": {"name": "Kwame Asante", "email": "kwame@example.com"},
"items": [
{
"name": "Web Development",
"description": "Landing page design",
"quantity": 1,
"price": 800, # GHS 8.00
"image": "https://example.com/web-development.png",
},
{
"name": "Hosting (1 year)",
"description": "Cloud hosting plan",
"quantity": 1,
"price": 200, # GHS 2.00
"image": "",
},
],
"currency": "GHS",
"channels": ["mobile_money"],
"redirect_url": "https://yourapp.com/payment/callback",
"meta": {"orderId": "order_123"},
},
timeout=30,
)
payload = response.json()
if response.status_code != 201:
raise RuntimeError(payload.get("error", "checkout creation failed"))
print(payload["data"]["checkout_url"])<?php
$body = [
'type' => 'checkout',
'recipient' => ['name' => 'Kwame Asante', 'email' => 'kwame@example.com'],
'items' => [
[
'name' => 'Web Development',
'description' => 'Landing page design',
'quantity' => 1,
'price' => 800, // GHS 8.00
'image' => 'https://example.com/web-development.png',
],
[
'name' => 'Hosting (1 year)',
'description' => 'Cloud hosting plan',
'quantity' => 1,
'price' => 200, // GHS 2.00
'image' => '',
],
],
'currency' => 'GHS',
'channels' => ['mobile_money'],
'redirect_url' => 'https://yourapp.com/payment/callback',
'meta' => ['orderId' => 'order_123'],
];
$curl = curl_init('https://api.seevplus.com/api/v1/developer/payments');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEEV_CHECKOUT_API_KEY'),
'Idempotency-Key: order_123_attempt_1',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$payload = json_decode($response, true);
if ($status !== 201) {
throw new RuntimeException($payload['error'] ?? 'checkout creation failed');
}
echo $payload['data']['checkout_url'];package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
body, err := json.Marshal(map[string]any{
"type": "checkout",
"recipient": map[string]string{
"name": "Kwame Asante",
"email": "kwame@example.com",
},
"items": []map[string]any{
{
"name": "Web Development",
"description": "Landing page design",
"quantity": 1,
"price": 800, // GHS 8.00
"image": "https://example.com/web-development.png",
},
{
"name": "Hosting (1 year)",
"description": "Cloud hosting plan",
"quantity": 1,
"price": 200, // GHS 2.00
"image": "",
},
},
"currency": "GHS",
"channels": []string{"mobile_money"},
"redirect_url": "https://yourapp.com/payment/callback",
"meta": map[string]string{"orderId": "order_123"},
})
if err != nil {
panic(err)
}
req, err := http.NewRequest(
http.MethodPost,
"https://api.seevplus.com/api/v1/developer/payments",
bytes.NewReader(body),
)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("SEEV_CHECKOUT_API_KEY"))
req.Header.Set("Idempotency-Key", "order_123_attempt_1")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var payload struct {
Error string `json:"error"`
Data struct {
CheckoutURL string `json:"checkout_url"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
panic(err)
}
if resp.StatusCode != http.StatusCreated {
panic(payload.Error)
}
fmt.Println(payload.Data.CheckoutURL)
}The request body is the Seev gateway initiation body, passed through unchanged apart from meta. Do not set organizationId or user_id yourself. SeevPlus writes organizationId, user_id, seev_origin and seev_environment into meta from the authenticated key, overwriting any values you send under those four names, and preserves the rest of your meta untouched.
A successful create returns HTTP 201 and the gateway initiation response, again unchanged, so gateway fields such as env, item IDs, item session IDs, empty values and fields added by the gateway in future all survive the round trip. Amounts in the response follow the same smallest-unit convention, so "amount": 1000 is GHS 10.00.
{
"success": true,
"data": {
"amount": 1000,
"channels": ["mobile_money"],
"checkout_url": "https://pay.seevplus.com/PAY-20260718-f23159df-9df8-4e69-8306-c11d71ead351",
"currency": "GHS",
"env": "production",
"expires_at": "2026-07-18T21:08:46.776302Z",
"items": [
{
"id": "f0c72f6b-59fc-4371-b68b-540f54d12f5b",
"session_id": "d636387f-6872-469e-be31-76a79301fcb1",
"name": "Web Development",
"description": "Landing page design",
"quantity": 1,
"price": 800,
"image": "https://example.com/web-development.png"
},
{
"id": "62342bee-7eeb-4f31-984a-96718e2fd997",
"session_id": "d636387f-6872-469e-be31-76a79301fcb1",
"name": "Hosting (1 year)",
"description": "Cloud hosting plan",
"quantity": 1,
"price": 200,
"image": ""
}
],
"recipient": {
"email": "kwame@example.com",
"name": "Kwame Asante"
},
"redirect_url": "https://yourapp.com/payment/callback",
"reference": "PAY-20260718-f23159df-9df8-4e69-8306-c11d71ead351",
"status": "pending"
},
"request_id": "fb4f88c-9429-41d7-b3ca-5e4226a50736",
"timestamp": "2026-07-18T20:38:46.779595765Z"
}Store data.reference against your order, then redirect the customer to data.checkout_url. SeevPlus records its own transaction from the gateway's answer, copying:
- the gateway-calculated amount and currency
- the status and the reference
- the checkout URL and the expiry timestamp
Request fields
Payment context recorded on the session. Use checkout for your own app
or storefront flow.
Total in the currency's smallest unit, so 10000 is GHS 100.00.
Mutually exclusive with items, and one of the two is required.
Objects with name, quantity and price, where price is per unit
in the smallest unit. description and image are optional. Use
instead of amount.
One of GHS, USD, EUR, GBP, NGN, USDC, USDT. Uppercase.
Customer's full name, shown on the hosted checkout page.
Deliverable email address. Receipts are sent here.
Customer's phone number.
HTTP or HTTPS URL the customer returns to after checkout. Treat arrival there as unverified.
Restricts the methods offered. Omit to offer every method enabled for hosted checkout.
Top-level, not inside recipient. Required when channels is
["ussd"].
MTN, VODAFONE or AIRTEL. Required when channels is ["ussd"].
Seconds to wait before the USSD intent executes, 0 to 120. Ignored outside USSD.
A percentage, deducted from the subtotal before tax.
A percentage, applied to the amount left after the discount.
A SUB_ code that routes a percentage of this payment to a subaccount.
Cannot be combined with the crypto channel. See
Subaccounts.
Your own JSON, passed to the gateway. The keys organizationId,
user_id, seev_origin and seev_environment are overwritten by
SeevPlus.
Handle a failed create
Errors come back with the HTTP status below and a body carrying the same text twice, as {"error": "...", "message": "..."}. There is no machine-readable code field, so branch on the status.
The body could not be read or is not valid JSON. Check Content-Type: application/json and that you sent a complete body.
The body parsed but is an array or a scalar. Send a single JSON object.
The header exceeds 128 characters. Derive a shorter key, such as a hash of your order ID.
Check organization verification before retrying.
Neither Authorization: Bearer nor X-API-Key was sent.
The secret does not match a stored hash, or predates this route. Rotate the key once in the Developer Dashboard, then use the new secret.
Regenerate the key from inside the organization that should receive the payment.
The key has an IP allowlist and your server's address is not on it. Add the calling server's egress IP, or turn the allowlist off.
Seev has paused live payments for your business. No session was created and a retry will not help. Contact support to have them turned back on. Sandbox keys keep working in the meantime.
The gateway's own status code and body are appended to message. No
session was created, so a retry is safe. A conflicting idempotency key
and an upstream rate limit both arrive this way.
A 502 means no checkout session exists, so retry with a fresh idempotency key rather than reusing the one that failed.
Retry safely with an idempotency key
Send Idempotency-Key on every create. The header is optional, and without it every request creates a new payment, which is how a network timeout turns into a customer being charged twice. Generate one stable key per logical order and reuse it for retries of that order rather than generating a new random key on each attempt.
SeevPlus forwards Idempotency-Key upstream rather than deduplicating on its own. An identical retry within 24 hours replays the original response, and the same key sent with a different body is rejected and reaches you as a 502 carrying the upstream conflict. Keys are limited to 128 characters. See Idempotency for the full behaviour.
Treat this as a safety net rather than a guarantee, and keep your own handler safe to run twice.
The original response is replayed. Nothing new is created.
Rejected upstream and returned as 502. Nothing is created.
24 hours. After that the key is forgotten and a new session is created.
SeevPlus stores the key and a SHA-256 hash of the outgoing request body on the transaction it records, so a duplicate is at least visible during reconciliation even where it was not prevented.
Initiate a direct USSD payment
USSD returns a dial intent instead of a page to redirect to, which makes it a server-driven flow rather than a hosted one. It must be the only entry in channels, and phone and network go at the top level of the body rather than inside recipient.
curl -X POST "https://api.seevplus.com/api/v1/developer/payments" \
-H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" \
-H "Idempotency-Key: order_456_ussd_1" \
-H "Content-Type: application/json" \
-d '{
"type": "checkout",
"recipient": {
"name": "Ama Mensah",
"email": "ama@example.com"
},
"amount": 10000,
"currency": "GHS",
"channels": ["ussd"],
"phone": "233241234567",
"network": "MTN",
"delay": 0,
"redirect_url": "https://yourapp.com/payment/callback",
"meta": {
"orderId": "order_456"
}
}'The amount of 10000 above is GHS 100.00, in the currency's smallest unit. A successful USSD response carries phone, network, delay and an intent object describing the lifecycle, and carries no checkout_url:
{
"success": true,
"data": {
"reference": "PAY-20260825-abc123",
"status": "pending",
"phone": "233241234567",
"network": "MTN",
"delay": 0,
"intent": {
"type": "ussd",
"status": "pending",
"delay": 0,
"execute_at": "2026-08-25T12:00:00Z",
"requires_action": false
}
}
}Because there is no redirect to bring the customer back, store the returned reference and poll the verify route until the session reaches a final status. Verification responses for a USSD session can also include ussd_intent_status, ussd_execute_at, ussd_initiated_at and ussd_failure_reason, which is where to look when a customer says no prompt arrived.
Split a payment with a subaccount
Pass a subaccount code as subaccount and Seev splits the payment between your main balance and that subaccount, each settling with its own payout record. Fees come off the top, then both sides take their percentage.
{
"type": "checkout",
"amount": 10000,
"currency": "GHS",
"subaccount": "SUB_9f2k1p7qw4r8c3e1",
"recipient": { "name": "Ama Mensah", "email": "ama@example.com" },
"redirect_url": "https://yourapp.com/payment/callback"
}The code is resolved before the session is created. It has to belong to your organization in the same environment as the key, be active, be verified, and hold the same currency as the payment. If any of those fails the create is rejected rather than the split being quietly dropped, and it reaches you as a 502 carrying the upstream reason.
The share comes from the subaccount's default and is fixed onto the session at creation, so changing it later does not rewrite payments already made. A successful session carries a split object with the code, the business name and the share applied.
A subaccount has to exist before you can attach it. Create one from the dashboard or through the API, then pass its code here. Subaccounts covers setting one up and what happens to the money once a split payment succeeds.
Choose the channels you offer
Omit channels to offer every method enabled for hosted checkout, or pass a subset to restrict the hosted page. The three values below are accepted today.
MTN MoMo, Telecel Cash and AT Money, on a Ghana cedi checkout.
A server-side intent rather than a button on the hosted page. Do not combine it with another value.
Card issuing is live so your team can spend from a funded virtual card. Collecting from a customer by card is not.
Appears in dashboard previews as upcoming.
The customer pays in USDC on Stellar, and the money lands in your USDC
account. Production only, and not available on a payment with a
subaccount.
Before you offer USDC
A customer paying in USDC is paying into your USDC account, so you need one. You do not get one by default.
Automatically. The first live request that includes crypto opens
your USDC account for you. That one request can take a few seconds
longer than usual.
Ahead of time. In the dashboard, open Accounts and choose Add USDC. Do this first if you would rather not have the wait land on a customer's checkout.
Your organization has to be verified to hold USDC. If the account cannot
be opened, Seev leaves crypto out and the checkout offers your other
channels. Read channels in the response to see what the customer will
be shown.
Do not offer a customer a method marked coming soon until it is selectable on the live checkout page they opened. Dashboards and previews show unavailable methods as upcoming, which is easy to read as available. Payment Methods covers current availability and what to tell a customer whose payment stalls.
Verify the payment before you fulfil
The redirect to your redirect_url is not proof of payment. Anyone who
knows the URL can request it. Fulfil only after a server-side check returns
a paid status.
Look the session up by the gateway reference you stored at creation, or by the session reference you receive on the redirect. The route takes no API key.
curl -X GET \
"https://api.seevplus.com/api/v1/developer/payments/$SESSION_REF"const response = await fetch(
`https://api.seevplus.com/api/v1/developer/payments/${sessionRef}`,
);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'verification failed');
}
const paid = ['completed', 'success'].includes(payload.data.status);import requests
response = requests.get(
f"https://api.seevplus.com/api/v1/developer/payments/{session_ref}",
timeout=30,
)
payload = response.json()
if not response.ok:
raise RuntimeError(payload.get("error", "verification failed"))
paid = payload["data"]["status"] in ("completed", "success")<?php
$curl = curl_init(
'https://api.seevplus.com/api/v1/developer/payments/' . rawurlencode($sessionRef)
);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$payload = json_decode($response, true);
if ($status < 200 || $status >= 300) {
throw new RuntimeException($payload['error'] ?? 'verification failed');
}
$paid = in_array($payload['data']['status'], ['completed', 'success'], true);package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
func main() {
sessionRef := "SESSION_REFERENCE"
resp, err := http.Get(
"https://api.seevplus.com/api/v1/developer/payments/" + url.PathEscape(sessionRef),
)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var payload struct {
Error string `json:"error"`
Data struct {
Status string `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
panic(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(payload.Error)
}
paid := payload.Data.Status == "completed" || payload.Data.Status == "success"
fmt.Println(paid)
}Session status
Not yet confirmed. Do not fulfil.
Funds confirmed. Safe to fulfil.
Also paid. Treat it the same as completed.
The payment did not go through.
Stopped before payment.
Treat completed or success as paid, and everything else as not fulfilled.
Handle a failed verify
The path segment was empty or whitespace.
The reference does not match a session.
The gateway did not answer the verification call.
Never map a 404 or a 502 onto a failure in your own order record. Neither one says the customer was not charged.
Fix common integration problems
Recurring or saved-card payments. Each session is a single charge.
- Refunds through this API.
Durable gateway-event processing. Outbound webhooks fire from a status synchronization rather than an independent gateway event stream, which Verify Payments covers.
Official SDKs, browser and mobile packages, and platform plugins such as WordPress or WooCommerce.