# Changelog (/docs/changelog) Changes are grouped by area: **Platform** covers the Seev Dashboard, storefront, and payment experiences; **API** covers the REST API and webhooks; **Documentation** covers guides and references; **SDK** covers the official client libraries. Dates follow the ISO 8601 format (YYYY-MM-DD). Breaking changes are marked with a **`BREAKING`** badge. *** ## Platform [#platform] ### 2026-09-29 [#2026-09-29] * **Added** Two-step sign-in. Turn it on from **Settings**, **Personal profile** to be asked for a code by email or text message each time you sign in. It is optional and off by default. See [Settings](/docs/settings#turn-on-two-step-sign-in). * **Added** USDC accounts on request. Verified organizations add a USDC account from **Accounts** with one click, and it is opened automatically the first time you set up cards or take a payment in USDC. * **Added** Subaccount payouts. A subaccount's share of each split payment is now paid out to the subaccount's own mobile money number or bank account, on your payout schedule. Payout history shows who each payout went to. ### 2026-09-26 [#2026-09-26] * **Added** USDC as a checkout payment method. Customers can pay any checkout, invoice or payment link in USDC on Stellar by scanning a QR code or sending to the shown address and memo, then confirming with the transaction hash. Funds settle straight into the organization's USDC account and never enter the GHS payout cycle. Enable the crypto channel on a session to offer it. * **Added** A live preview to Branding. The Checkout, Invoice and Receipt tabs render the same components customers see, updating as you type, so what you save is exactly what goes out. The page is now organised into five short sections: name, logo, colours, invoice style, and contact details. [Open Branding](https://seevplus.com/invoices/customize). * **Added** Clear feedback when a USD receiving account is paused or closed by the banking partner, with next steps, instead of an empty account panel. * **Improved** USDC sends now appear in the Accounts transaction list alongside mobile money withdrawals. * **Improved** The payment gateway is now the single source of truth for every transaction shown in the Dashboard, so totals, statuses and payout figures always agree. ### 2026-09-17 [#2026-09-17] * **Added** A card agreement and authorized user terms, presented before the first card is issued. * **Added** More business types in verification, so organizations pick the structure that actually matches their registration. * **Added** A second funding source for cards. Cards can now be topped up from the organization's Seev Plus payment balance as well as from USDC, with the rate and amounts shown before you confirm. * **Improved** US businesses now provide either an SSN or an EIN during verification, matching what the card issuer requires, rather than both. * **Improved** Business verification now captures a business phone number, which is required for card issuance. ### 2026-09-14 [#2026-09-14] * **Added** Custom product variants for storefronts. Create your own variant categories such as Weight, Flavour or Material, set a price adjustment and stock per option, and mark categories required or optional. Colour and Size are available as one-tap presets with swatches and standard sizes. ### 2026-09-11 [#2026-09-11] * **Added** A redesigned verification flow that matches the Seev Plus design system, with a progress rail, autosave on every step, and a clear review screen before submission. [Open verification](https://kyc.seevcash.com). * **Added** Proof of address, business website and a short business description to individual contractor verification, so reviewers have what they need on the first pass. * **Improved** Verification sessions no longer time out mid-flow; the app quietly refreshes your session in the background. * **Improved** The industry selector is fast on every device, and country and state lists are sorted alphabetically. * **Improved** Organizations that have not completed verification see a direct link to finish it on the Cards page instead of the card terms. ### 2026-09-08 [#2026-09-08] * **Added** Subaccounts for split payments. Route a share of each payment to a verified subaccount, with its own settlement, directly from the Dashboard. * **Added** A short cardholder details step, covering address, phone and date of birth, that every organization completes once before opening the Cards area. * **Improved** The payment links page has a cleaner layout with filters and clearer status. * **Fixed** Receipt downloads work again for all invoice styles. ### 2026-09-02 [#2026-09-02] * **Added** USD virtual cards. Verified businesses can issue cards for their team, view card details in the card modal, fund them from USDC, and track card transactions in the Dashboard. * **Added** A Payouts view for merchants showing each payout with its date range and the transactions it covers. * **Improved** A refreshed Dashboard design across Accounts, Transactions, Cards and empty states, with consistent spacing, typography and mobile layouts. ### 2026-08-26 [#2026-08-26] * **Added** Detailed payout history in Accounts, including each payout's covered date range, destination, settlement breakdown, reference, and constituent transactions. * **Improved** Payout balances and completion state now use one payment settlement ledger, eliminating conflicting payout totals from separate balance copies. ### 2026-08-25 [#2026-08-25] * **Improved** Next business day is now the default payout schedule. Adding an organization's first active payout destination activates scheduled payouts while preserving any schedule the organization explicitly selected. * **Added** Weekly transaction summary emails for organization owners, covering the previous completed week when the organization had transaction activity. * **Added** Customer-facing business-name review in Settings. The current name stays active during review, and approved names appear on checkout, invoices, payment links, storefronts, receipts, and payment notifications. ### 2026-07-11 [#2026-07-11] * **Added** Unified organization verification across Seev Plus and the KYC app. Organizations can verify as a registered business or individual contractor, save their progress, submit for review, and respond to requested changes. [Open verification](https://kyc.seevcash.com). * **Added** Verification-aware Dashboard banners with distinct guidance for setup, review, and rejected states. Rejected organizations see the review note and can return to the correct prefilled verification flow to edit and resubmit. * **Added** A separate transaction PIN for each organization, giving teams stronger control over sensitive actions such as withdrawals, transfers, card activity, and yield transactions. * **Added** Real Ghana cedi fiat wallets to account and balance views. GHS balances now come from the organization's fiat wallet instead of being estimated from its USD balance. * **Added** Organization payout scheduling with Manual, Next business day, Next week, and Next month options, plus selectable payout destinations. * **Added** Multiple payout destination types: SeevCash username, Ghana mobile money, Ghana bank account, Nigeria bank account, and USD bank account. * **Added** Complete Developer Webhooks management with endpoint creation and editing, event selection, enable/disable controls, delivery logs, retry actions, environment separation, and view-once signing secrets. [Manage webhooks](https://seevplus.com/developer/webhooks). * **Added** API Customer summaries to the Developer Dashboard, including customer fallbacks, transaction count, total volume, latest status, reference, environment, and last activity. * **Added** A first-run Developer overview that guides organizations without API keys to generate a product key, read the docs, and run a sandbox payment. Existing integrations continue to use the full reporting overview. * **Added** Payment-link filters and counts for All, Paid, Cancelled, and Pending links, plus cancellation controls for eligible links. * **Added** Invoice filters and counts for All, Draft, Sent, Pending, Paid, Cancelled, and Overdue invoices. * **Added** Storefront product filters for All, Enabled, Disabled, and Low stock products. * **Added** Infinite scrolling to public storefront product lists so stores are no longer limited to a fixed initial product count. * **Added** Direct product-image upload from empty thumbnails in the products table and product detail view. Products without an image cannot be enabled publicly and show clear guidance explaining why. * **Improved** POS and stock-update tools now focus on active products, keeping hidden inventory out of day-to-day sales workflows. * **Improved** Payment-link detail pages remove copy, QR, and active-checkout actions after a link is paid, cancelled, or otherwise no longer payable. * **Improved** KYC and KYB forms keep each organization's details separate, show clear validation errors, preserve saved progress, and allow users to review completed details before submission. * **Improved** Organization business profiles use the selected organization's individual or business verification details and route edits back through the KYC app. * **Improved** Settings now provides a focused organization workspace for business profile, team access, payout scheduling, and personal profile controls. * **Fixed** Decimal checkout amounts now render correctly in payment gateway previews. * **Fixed** Verification type selection no longer leaves a stale “Verification type is required” error after an option is selected. * **Fixed** Responsive Dashboard headers no longer show the desktop greeting at awkward tablet-width breakpoints. ### 2026-07-01 [#2026-07-01] * **Added** Organization onboarding for new Seev Plus users. Merchants must now create or select an organization before entering business dashboards, making organization ownership the default for stores, invoices, payment links, developer tools, wallets, and business verification. [Open Seev Plus](https://seevplus.com). * **Added** Organization switching and creation in the Dashboard sidebar so merchants can manage multiple businesses from one account. * **Added** A global payment-readiness banner for pages where merchants can prepare business tools but must complete KYB or KYC before accepting live payments. The banner links to the KYC app and is hidden on pages that already provide their own verification guidance or empty-state onboarding. * **Added** Storefront POS checkout tools for walk-in sales. Merchants can search products, build a cart, create POS orders, open payment links, and show QR codes for customers to scan. [Open Storefronts](https://seevplus.com/store). * **Added** POS order indicators and Store order / POS order filters in the storefront orders table so merchants can distinguish online storefront orders from in-person sales. * **Added** Product bulk upload for storefronts, including a CSV template, drag-and-drop upload, product review, per-product image attachment, and safe imports that remain hidden until the merchant enables them. * **Added** Bulk product table actions for selected products, including delete confirmation, disable from store, and add to featured. * **Added** Bulk stock update controls for storefront products, with editable stock fields, product exclusion controls, and confirmation before applying stock updates. * **Added** guided walkthroughs for storefront products, stock updates, bulk upload review, and POS workflows to help merchants understand the table, visibility toggles, stock tools, CSV imports, and checkout creation. * **Added** Scan-to-pay QR sections for generated payment links. Merchants can show a QR code, open a fullscreen scanner-friendly view, copy the payment URL, or open the link directly. [Open Payment Links](https://seevplus.com/payment-links). * **Added** Customer creation from the Customers page while keeping system-created paying customers distinct from manually saved invoice and payment-link customers. [Open Customers](https://seevplus.com/customers). * **Added** Customer snapshot stats and a table-based customer list that keeps avatars for a more readable customer view. * **Improved** Storefront products, payment links, customers, developer API keys, developer transactions, webhooks, and webhook logs now use more consistent table experiences and empty states. * **Improved** Storefront product management now includes a cleaner mobile add-product flow, more usable product type cards, better product form spacing, and a more compact mobile header. * **Improved** Storefront product detail pages, mobile Dashboard header, Dashboard action cards, invoice creation forms, invoice branding forms, payment-link creation forms, and storefront customization screens for better mobile layout and spacing. * **Improved** Payment-link creation preview now reflects the entered amount, selected currency, and merchant name instead of static placeholder content. * **Improved** Payment links now load smoothly without briefly showing the wrong table or empty state. * **Improved** KYC onboarding role selection with clearer business vs individual choices, friendlier copy, better selected states, and mobile-friendly cards. [Open KYC](https://kyc.seevcash.com). * **Improved** KYB business email is now editable and validated so organizations can provide a business email instead of reusing the signed-in user's personal email. * **Fixed** Store navigation now opens the correct storefront or setup screen when an organization has not created a store yet. * **Fixed** Dashboard balance cards default unavailable values to zero instead of showing a shimmer indefinitely. * **Fixed** Empty states for invoices, storefronts, payment links, and Seev API no longer show the global payment-readiness banner. ### 2026-06-23 [#2026-06-23] * **Added** A complete Developer Dashboard for enabling API access, accepting developer terms, choosing API products, and managing sandbox and production environments. [Open the Developer Dashboard](https://seevplus.com/developer). * **Added** Product-specific API key management for Checkout API and Exchange Widget API. Merchants can create multiple named keys per product and optionally configure IP allowlists. [Manage API keys](https://seevplus.com/developer/api-keys). * **Added** View-once credential screens for newly generated and rotated API keys. Public and secret keys can be copied immediately, while existing records display a safely masked secret-key prefix. * **Added** API key rotation and deletion controls with confirmation dialogs and immediate status updates. * **Added** A Developer Transactions table with sandbox and production separation, period and status filtering, and a right-side transaction detail panel containing references, customer information, timestamps, metadata, and additional response fields. [View API transactions](https://seevplus.com/developer/transactions). * **Added** Developer overview reporting for transaction activity and failed transactions, including `day`, `week`, `month`, and `year` periods. * **Added** A webhook workspace with webhook and delivery-log tables, filters, search, empty states, and a create-webhook form for selecting endpoint events. [Manage webhooks](https://seevplus.com/developer/webhooks). * **Added** Sandbox and production controls throughout the developer section. Accounts that have not completed KYC or KYB see clear verification guidance, while unavailable production controls are disabled with explanatory tooltips. * **Added** Sandbox checkout actions that allow developers to simulate successful and declined payments without being charged. * **Added** Clear payment-verification and checkout-redirect loading screens in the payment widget and storefront, preventing payment forms or empty carts from flashing while callbacks complete. * **Added** An organization selector to the Dashboard sidebar and an organization creation flow for merchants preparing to manage multiple organizations. * **Added** First name and last name fields to registration. * **Improved** Settings now has a structured account and app settings index. Existing user details are available under Personal Profile, and successful profile updates immediately refresh the local Dashboard user. * **Improved** Email verification styling with softer borders and a less visually harsh layout. * **Improved** Developer API keys, transactions, webhooks, and webhook logs now use the shared Dashboard data-table experience while preserving their dedicated empty states. * **Improved** Payment links now preserve completed checkout URLs as receipt links. Paid links cannot generate another checkout link after the original link expires. [Open Payment Links](https://seevplus.com/payment-links). * **Improved** Invoices, storefront management, and payment links now include easy-to-find documentation links. [Open Invoices](https://seevplus.com/invoices) or [manage your storefront](https://seevplus.com/store). * **Improved** Storefront checkout keeps customers informed while their secure payment page is prepared, and carts are cleared without briefly showing an empty-cart screen. * **Improved** Payment widget sandbox sessions use the normal checkout interface, explain that no charge will occur, and provide explicit success and declined test actions. ### 2026-05-12 [#2026-05-12] * **Added** Storefront management UI — merchants can now create and manage storefronts directly from the Dashboard. * **Improved** Payment gateway now supports branding customisation with logos and colour schemes for a more seamless checkout experience. * **Fixed** Invoice notes were not saving correctly when created via the Dashboard UI. *** ## API [#api] ### 2026-09-29 [#2026-09-29-1] * **Added** `GET /api/v1/developer/payouts` and `GET /api/v1/developer/payouts/{id}` for reading payout history. Filter by `subaccount` to show a partner the payouts made to them. See [Subaccounts](/docs/payments/subaccounts#payouts). ### 2026-09-28 [#2026-09-28] * **Added** `GET /api/v1/developer/settlement-banks`, which lists every mobile money network and bank code a subaccount's `settlement_bank` accepts. * **Added** `POST /api/v1/developer/resolve-account`, which returns the name a mobile money number is registered to, so you can confirm the account holder before creating a subaccount. * **Added** `DELETE /api/v1/developer/subaccounts/{code}` for removing a subaccount that has never been attached to a payment. * **Improved** Sandbox subaccounts are verified on creation, so split payments can be tested straight away. * **Improved** Mobile money subaccounts created through the API are verified immediately when the account name matches the network's record. * **Improved** Subaccount requests that are refused now return `400`, `409` or `422` with the reason, instead of `502`. ### 2026-09-24 [#2026-09-24] * **Added** A `crypto` channel for checkout sessions. Include it in `channels` when initiating a session, call `POST /charge/{reference}/crypto/prepare` to get the USDC amount, merchant address, memo and a 15 minute quote, then confirm with the Stellar transaction hash via `POST /charge/{reference}` and `payer_input.mode: "wallet"`. * **Improved** Crypto confirmation errors state exactly what did not match: expected versus paid amount, missing memo, wrong asset, or wrong destination. Payments above the quoted amount are accepted; payments below it are rejected. ### 2026-09-15 [#2026-09-15] * **Added** The merchant Payouts API. Send mobile money payouts from your gateway balance, read your balance and activity, and track each payout's status. Payouts require the `payouts:write` scope, granted per merchant. See the [Payouts API reference](/docs/payouts). * **Improved** Payout responses no longer expose internal provider details. ### 2026-09-08 [#2026-09-08-1] * **Added** Subaccount management endpoints for split payments: create and update subaccounts, and list them with their verification status. Charges can allocate a portion of each payment to a subaccount. See [Subaccounts](/docs/payments/subaccounts). ### 2026-08-25 [#2026-08-25-1] * **Added** Direct USSD checkout initiation with a required phone and supported network, an optional delay, and intent lifecycle fields available during verification. ### 2026-07-11 [#2026-07-11-1] * **Added** Signed webhook deliveries with a view-once signing secret and HMAC-SHA256 verification headers, allowing developers to confirm that events genuinely came from Seev. * **Added** Webhook delivery retries for successful and failed events, with a new event ID for every retry and complete delivery logs for troubleshooting. * **Added** `payment.succeeded` and `payment.failed` webhook events for Checkout API integrations. ### 2026-07-01 [#2026-07-01-1] * **Added** POS receipt email capture so walk-in customers can choose where to receive their payment receipt before completing checkout. ### 2026-06-23 [#2026-06-23-1] * **Added** Sandbox and production environments for API keys and developer activity. * **Added** Product-specific API key creation, listing, rotation, and deletion. * **Added** View-once secret-key responses for API key creation and rotation. Subsequent key-list requests return public identifiers and masked secret-key prefixes instead of full secrets. * **Added** Developer transaction retrieval with environment, period, and status filters. * **Added** Sandbox payment simulation for testing successful and failed checkout results without moving money. * **Improved** Developer transaction records can expose customer details, gateway references, descriptions, metadata, and timestamps for richer Dashboard inspection. * **Improved** API key responses now provide clearer product, environment, status, public-key, scope, and safely masked secret-key details. ### 2026-05-12 [#2026-05-12-1] * Published v0.1.0 of the API specification to the public documentation. This includes endpoints for payments, payouts, customers, and webhooks. *** ## Documentation [#documentation] ### 2026-09-26 [#2026-09-26-1] * **Added** A "Try it in your account" sign-up prompt in the contents rail on every docs page, which stays visible however long the page is. * **Improved** The Stellar anchor reference now reflects the live anchor host, mainnet USDC, and SEP-31 support. ### 2026-09-15 [#2026-09-15-1] * **Added** The Payouts API reference as its own section covering balances, mobile money payouts, statuses and permissions. ### 2026-09-08 [#2026-09-08-2] * **Added** Subaccounts guides for both the Dashboard and the API. * **Improved** A redesigned docs site with a new home page, clearer navigation between "Run a business" and "Build with the API", and page styling that matches Seev Plus. ### 2026-08-25 [#2026-08-25-2] * **Improved** Payout guides now reflect the Next business day default and explain when the first active payout destination activates the schedule. * **Added** Direct USSD initiation, response, verification, and troubleshooting guidance to the Checkout API and payment-method guides. * **Added** Business identity review and weekly transaction summary guidance. ### 2026-07-11 [#2026-07-11-2] * **Added** A beginner-friendly [Getting Started guide](/docs/payments) that connects organization setup, verification, payment options, wallets, PINs, payouts, and the first-payment checklist. * **Added** A central [Troubleshooting guide](/docs/troubleshooting) for common organization, verification, checkout, storefront, wallet, payout, PIN, and API problems. * **Improved** The documentation is now organized around customer goals such as getting paid, managing money, running a store, and building an integration, with clearer prerequisites, step-by-step workflows, status explanations, and recovery guidance. * **Added** Organization workspace guidance covering business switching, ownership, membership roles, and keeping each organization's data separate. [Read about organizations](/docs/organizations). * **Added** Account guides for fiat and stablecoin wallets, transaction PINs, virtual accounts, cards, yield, balances, deposits, and transaction history. [Read the account guide](/docs/accounts). * **Added** Dashboard customer documentation explaining manually saved recipients, transaction-derived customers, customer snapshots, and the separation between Dashboard and API customers. [Read the customer guide](/docs/organizations/customers). * **Added** Organization settings documentation for team access, payout schedules, and payout destinations including SeevCash, mobile money, local bank accounts, and USD bank accounts. [Read payout settings](/docs/settings/payouts). * **Added** API Customer documentation for environment-specific, read-only customer summaries derived from developer transactions. [Read API Customers](/docs/developer/customers). * **Improved** Developer guidance now connects authentication, environments, Checkout API, webhooks, idempotency, and production readiness without separating the workflow into a standalone reference page. [Open the Developer guide](/docs/developer). * **Improved** Storefront documentation with POS orders, product filters and pagination, public infinite scrolling, CSV bulk import, image requirements, bulk actions, stock updates, categories, shipping, business hours, policies, and customization controls. [Read the storefront guide](/docs/payments/storefront). * **Improved** Invoice documentation with optional due dates, reusable customers, required line items, filters and status counts, branding, verification-gated checkout links, and paid-invoice receipt behavior. [Read the invoice guide](/docs/payments/invoices). * **Improved** Payment Link documentation with scan-to-pay QR codes, filters and counts, cancellation, and the actions available after a link is paid, expired, or cancelled. [Read the Payment Links guide](/docs/payments/links). * **Improved** Developer onboarding now reflects that accepting terms enables the workspace but does not generate keys. API key docs now cover view-once credentials, per-product keys, rotation, deletion, IP allowlists, and production verification requirements. * **Improved** Developer transaction documentation now covers detail panels, customer data, metadata, references, timestamps, settlement, and API customer summaries. * **Improved** Verification documentation now uses unified organization verification types and statuses, including drafts, review, approval, rejection notes, editing, and resubmission. [Read verification guidance](/docs/organizations/verification). * **Improved** SDK guidance now marks all SDKs, mobile packages, browser packages, and platform plugins as coming soon, and directs production integrations to the REST API. [Review SDK options](/docs/sdks). * **Improved** Payment-method guidance now separates methods currently available on hosted checkout from upcoming options, so merchants know what customers can actually use today. * **Fixed** Webhook error guidance now consistently requires rejecting invalid signatures instead of returning a successful response. * **Fixed** The documentation homepage now directs developers only to currently available API references and supported capabilities. ### 2026-07-01 [#2026-07-01-2] * **Improved** The public docs homepage and mobile documentation layout were refined for smaller screens. [Open Docs](https://docs.seevplus.com/docs). * **Improved** Product, storefront, invoice, payment-link, developer, and checkout documentation links in Seev Plus now point users to the most relevant docs from the Dashboard. ### 2026-06-23 [#2026-06-23-2] * **Added** A complete [Developer Dashboard guide](/docs/developer) covering activation, API products, environments, settlement, and production access. * **Added** Dedicated guides for [API keys](/docs/developer/api-keys), [API transactions](/docs/developer/transactions), and [webhooks](/docs/developer/webhooks). * **Improved** Authentication guidance was consolidated into the API key documentation so credential setup and security guidance live in one place. * **Improved** Webhook concepts, request flow, responses, retries, and best practices were consolidated into the developer webhook guide. Event reference documentation remains marked as coming soon. * **Improved** Checkout documentation now explains that developer access must be activated before API keys can be generated and links directly to the API key guide. * **Improved** Payment links, invoices, storefronts, and payout documentation were aligned with the currently deployed Dashboard behavior and supported payment methods. *** ## SDK [#sdk] ### Coming soon [#coming-soon] * Official SDKs and platform plugins are not currently published for production use. * Use the REST API for checkout creation, verification, webhooks, and idempotency until SDK support is announced in these docs. # Errors (/docs/errors) 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](/docs/payments/checkout). ## Read an error response [#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. ```json { "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" } ``` | Field | Type | Required | Description | | --------- | -------- | --------------------------- | ------------------------------------------------------------------------------------------- | | `error` | `string` | Always present on a failure | Human-readable failure description, intended for your logs. Not safe to show to a customer. | | `message` | `string` | Always present on a failure | Duplicate 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](#show-the-customer-a-safe-message). ### Gateway codes arrive inside the message, not as a field [#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: ```json { "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 [#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. | Status | Cause | What to do | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `400` | The 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. | | `401` | The 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. | | `403` | The 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. | | `502` | SeevPlus 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. ```typescript type CheckoutResult = | { ok: true; checkoutUrl: string } | { ok: false; retryable: boolean }; async function createCheckout(orderId: string): Promise { 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 [#recover-from-a-duplicate-or-conflicting-key] Idempotency responses come back from the gateway leg and are documented in full under [Idempotency](/docs/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 [#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. | Status | Cause | What to do | | ------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `400` | The 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. | | `404` | The 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. | | `502` | SeevPlus 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. ```typescript 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 [#decide-whether-to-retry] Retry only the statuses that describe a failure in transit rather than a failure in your request. | Status | Retry | Why | | -------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------- | | `400`, `401`, `403`, `404` | No | The same request produces the same result. Fix the request, the key, or the resource reference. | | `409` | No | The idempotency key is already bound to a different body. Change the key or the body first. | | `502` | Yes, with the same key | The gateway leg failed and the outcome is unknown. | | Network rejection | Yes, with the same key | The 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 [#verify-a-webhook-before-acting-on-it] SeevPlus signs each delivery and sends it as `POST` with these headers. | Header | Type | Required | Description | | ------------------- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------- | | `X-Seev-Event-ID` | `string` | Always sent | Event log identifier. Use it as your deduplication key. | | `X-Seev-Event-Type` | `string` | Always sent | Either `payment.succeeded` or `payment.failed`. | | `X-Seev-Timestamp` | `string` | Always sent | Unix seconds at signing time, and the first input to the signature. | | `X-Seev-Signature` | `string` | Always sent | `v1=` followed by the hex HMAC-SHA256 of `.`, 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. ```typescript 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 [#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](/docs/developer/webhooks) for endpoint setup and the retry controls. ## Show the customer a safe message [#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. ```typescript 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. # Idempotency (/docs/idempotency) An idempotency key lets you retry a request that may already have succeeded. When you send the same key twice, the API returns the original response instead of creating a second payment, so a timeout, a crashed process, or a double-clicked button costs the customer nothing. Amounts in every example on this page are in the currency's smallest unit, so `5000` with currency `GHS` means GHS 50.00. ## Retry a payment safely [#retry-a-payment-safely] Send an `Idempotency-Key` header on the `POST` request you want to protect. The header is optional on Checkout API payment initiation, and without it every call creates a new payment. ```bash curl https://api.seevplus.com/api/v1/developer/payments \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: charge_order_10482" \ -d '{ "type": "checkout", "amount": 5000, "currency": "GHS", "recipient": { "name": "Jane Doe", "email": "jane@example.com" }, "redirect_url": "https://yourapp.com/payment/callback" }' ``` Repeat that request with the same key and the same body, and you get the original payment back with `200`. No second payment is created and no charge is attempted. ## Choose a key [#choose-a-key] Derive the key from the operation it protects rather than generating one at call time. A key built from your order ID stays the same across every retry of that order, which is the property that makes retries safe. A fresh UUID per attempt makes each attempt look like new work and gives you no protection at all. Prefix the key with the operation type, as in `charge_order_10482`, so the key is identifiable in logs and cannot collide with a payout that happens to share an ID. Write the key to your own database before you call the API. If your process dies between the API responding and your code storing the result, the stored key is what lets you retry and recover the original payment. ```typescript // Reserve the key first, so a crash mid-request is recoverable. await db.orders.update({ id: orderId, idempotencyKey: `charge_order_${orderId}`, }); 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', 'Idempotency-Key': `charge_order_${orderId}`, }, body: JSON.stringify({ type: 'checkout', amount: 5000, // smallest unit: GHS 50.00 currency: 'GHS', recipient: { name: 'Jane Doe', email: 'jane@example.com' }, redirect_url: 'https://yourapp.com/payment/callback', }), }, ); const payload = await response.json(); await db.orders.update({ id: orderId, checkoutUrl: payload.data.checkout_url, }); ``` ## Handle a replayed or conflicting key [#handle-a-replayed-or-conflicting-key] A key is scoped to the endpoint it was first used on, so the same value on a different endpoint starts a separate record rather than colliding. Keys are limited to 128 characters; a longer one is rejected before the payment is attempted. A key is remembered for 24 hours from first use. After that window the same key is treated as new work and creates a new payment, so derive keys from the operation and retry within the day rather than resurrecting one later. | Status | When you get it | What to do | | ------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `201` | The key was used before with the same body. The original response is replayed. | Treat it as success. Do not create a second order record. | | `502` | The key was used before with a different body. | Do not retry as-is. Either send the original body with this key, or use a new key for the changed request. | A conflicting key is rejected upstream, and SeevPlus returns that as `502` with the upstream text inside `message`: ```json { "error": "Failed to initiate payment", "message": "failed to initiate checkout: status code 409: {\"success\":false,\"error\":\"IDEMPOTENCY_CONFLICT\",\"message\":\"Duplicate idempotency key with different body\"}" } ``` Branch on the status and log the message, the same way you would for any other upstream failure. See [Errors and codes](/docs/errors) for why the embedded code is not something to parse. Treat idempotency as a safety net rather than a guarantee. It removes the common double-charge, but your own handler should still be safe to run twice: write the key before you call, and check your own records before you create a second order. ## Deduplicate webhooks separately [#deduplicate-webhooks-separately] An idempotency key protects requests you send. It does nothing for webhooks you receive, which Seev may deliver more than once for the same event. Deduplicate those on the event ID in your handler, and read [Webhooks](/docs/developer/webhooks) for the delivery and retry behaviour. # Seev Plus Docs (/docs) Seev documentation covers two audiences in one place. If you run a business on Seev Plus, start with [Get paid with Seev Plus](/docs/payments) and the workspace guides. If you are integrating Seev into your own product, go to the [Developer Dashboard](/docs/developer) and the [Checkout API](/docs/payments/checkout). Every fact lives on exactly one page, and the rest link to it. # Payment Widget (/docs/seevanchor) > Everything you need to know to add crypto on/off-ramp payments to your website. *** ## What Is SeevCash? [#what-is-seevcash] SeevCash lets your customers buy and sell USDC (a US dollar-pegged cryptocurrency on the Stellar blockchain) using Mobile Money — directly from your website. * **Buy USDC** — Customer pays with MTN MoMo, Vodafone Cash, or AirtelTigo Money → receives USDC in their Stellar wallet * **Sell USDC** — Customer sends USDC → receives GHS in their Mobile Money wallet You don't need to handle any blockchain logic. We take care of everything. *** ## How It Works (User Experience) [#how-it-works-user-experience] ### Sell USDC Flow (Withdraw → Mobile Money Payout): [#sell-usdc-flow-withdraw--mobile-money-payout] 1. Merchant backend initializes a widget session (`POST /api/v1/widget/session`) 2. Merchant backend authenticates with the anchor via SEP-10 3. Merchant backend requests a SEP-24 withdraw interactive URL with merchant session fields 4. Customer is redirected to the interactive URL in an iframe/popup 5. Customer completes KYC and enters Mobile Money payout details 6. Customer sends USDC to the anchor's Stellar address with the provided memo 7. SeevCash receives USDC → disburses GHS to customer's MoMo (1–5 minutes) 8. Merchant receives webhook notification on completion *** ## Quick Start (5 Minutes) [#quick-start-5-minutes] ### Step 1: Register Your Merchant Account [#step-1-register-your-merchant-account] ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/merchant/register' \ --header 'Content-Type: application/json' \ --data-raw '{ "name": "Acme Payments", "legal_name": "Acme Payments Ltd.", "email": "payments@acmepay.com", "password": "MerchantPass123!" }' ``` **Response (201):** ```json { "data": { "id": "a227bfda-6a91-4749-a360-bbbd2eeb8eff", "name": "Acme Payments", "legal_name": "Acme Payments Ltd.", "email": "payments@acmepay.com", "status": "pending", "tier": "starter", "fee_base_bps": 100, "fee_markup_bps": 0, "monthly_volume_limit": 100000, "current_month_volume": 0, "stellar_public_key": "GCGZIEFPQL3L3FDXCQKEY273QIVBNTWNANH6RBHXASVJ7SE423VTIUZL", "is_test_mode": false, "created_at": "2026-05-13T12:30:07.592877236Z", "updated_at": "2026-05-13T12:30:07.592877236Z" }, "meta": { "request_id": "ffce4778-f125-4b5c-9ca7-42edfae871cb", "timestamp": "2026-05-13T12:30:07.720850177Z" } } ``` **Key fields:** | Field | Meaning | | -------------------- | ---------------------------------------------------------- | | `id` | Your unique merchant ID | | `status: "pending"` | Becomes `active` after KYB verification | | `tier: "starter"` | Determines your volume limits | | `fee_base_bps: 100` | Your base fee (100 bps = 1%) | | `stellar_public_key` | Auto-generated, used for blockchain auth behind the scenes | *** ### Step 2: Log In [#step-2-log-in] ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/merchant/login' \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "payments@acmepay.com", "password": "MerchantPass123!" }' ``` **Response (200):** ```json { "data": { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMjVlM2U1Zi1lODNhLTRlYmEtOTNiOS01MDQ0ZDc2NDU2NzMiLCJleHAiOjE3Nzg3NTQxNzQsImlhdCI6MTc3ODY2Nzc3NH0..." }, "meta": { "request_id": "7e5b2ee9-f130-44e0-8487-c9135633fa23", "timestamp": "2026-05-13T10:22:54.108683834Z" } } ``` > The `access_token` is a JWT valid for **24 hours**. Use it as `Authorization: Bearer ` for all merchant dashboard endpoints. *** ### Step 3: Create an API Key [#step-3-create-an-api-key] ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/merchant/apikeys' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "key_type": "public", "domains": ["http://localhost:3000", "https://myapp.example.com"] }' ``` **Response (201):** ```json { "data": { "api_key": { "id": "e16c5b6e-2c6e-47ee-98c9-7646f0eaae2f", "merchant_id": "f25e3e5f-e83a-4eba-93b9-5044d7645673", "key_type": "public", "key_prefix": "sv_public_07", "allowed_domains": [ "http://localhost:3000", "https://myapp.example.com" ], "allowed_ips": [], "permissions": ["onramp", "offramp"], "rate_limit_rpm": 600, "is_active": true, "created_at": "2026-05-13T12:32:30.232150975Z" }, "key": "sv_public_07386d7477d35f4243fc6b56a5d09aa58a244a742514b270d7ed2a646ca12f15" }, "meta": { "request_id": "ad1947d6-1a0d-447f-9b22-b376c5d76beb", "timestamp": "2026-05-13T12:32:30.241750377Z" } } ``` > ⚠️ **The `key` field is only shown once.** Store it securely. **Key types:** | Type | Use For | Security | | -------- | --------------------------------------- | ------------------------- | | `public` | Frontend widget script (`data-api-key`) | Domain-restricted | | `secret` | Server-to-server API calls | IP-restricted recommended | *** *** ## API Reference [#api-reference] Base URL: `https://widget-backend.seevcash.com` All responses are wrapped in `{ "data": {...}, "meta": {...} }` format. *** ### Initialize Widget Session [#initialize-widget-session] Creates a session for widget API calls. The widget does this automatically, but you can also call it server-side. > ⚠️ The `user_id` must be a **verified SEP-12 customer ID** obtained from either the Widget Customer API or the Anchor SEP-12 flow. See [Customer KYC](#customer-kyc-verification) below. ``` POST /api/v1/widget/session X-API-Key: Content-Type: application/json ``` ```json { "user_id": "6a3ce6751dbd5c0082a8d1b5" } ``` **Response (201):** ```json { "data": { "session_id": "2e72ff62-9d3e-4e54-8378-fa3c9e85bd0e", "session_token": "0c28199e-cd8b-4ea5-8c3b-980c778944e5", "merchant_id": "f25e3e5f-e83a-4eba-93b9-5044d7645673", "expires_at": "2026-05-13T11:54:53.289405068Z" } } ``` > Sessions expire after **30 minutes**. If the customer's KYC is not `ACCEPTED`, you'll receive a `403 KYC_NOT_APPROVED` error. *** ## Customer KYC (Verification) [#customer-kyc-verification] Before a customer can transact (buy/sell USDC), they must pass KYC verification. SeevCash supports two flows: | Flow | Best For | Who Handles KYC | | ------------------------ | ------------------------------------------------- | ------------------------------------- | | **Widget Customer API** | Merchants who want a simple REST API | Widget handles everything | | **Anchor SEP-12 Direct** | Developers familiar with Stellar anchor protocols | You interact with the anchor directly | Both flows produce a **customer ID** that you use as the `user_id` in all subsequent API calls. *** ### Option A: Widget Customer API (Recommended) [#option-a-widget-customer-api-recommended] The simplest way to onboard customers. You send customer details to the widget API, and it handles SEP-12 registration with the anchor behind the scenes. #### Step 1: Create a Customer [#step-1-create-a-customer] ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/widget/customer' \ --header 'X-API-Key: sv_secret_' \ --header 'Content-Type: application/json' \ --data-raw '{ "first_name": "Patrick", "last_name": "Oduro", "email_address": "patrick@example.com", "birth_date": "1990-05-15", "address": "123 Main Street", "city": "Accra", "address_country_code": "GHA", "mobile_number": "+233244123456" }' ``` **Response (201):** ```json { "data": { "id": "6a3ce6751dbd5c0082a8d1b5", "status": "ACCEPTED" }, "meta": { "request_id": "ddc93e15-410e-4f68-bc99-43953f951a98", "timestamp": "2026-06-25T08:27:33.622249018Z" } } ``` | Field | Description | | -------- | ------------------------------------------------------------------------------------------------ | | `id` | The customer ID — use this as `user_id` in all subsequent calls | | `status` | `ACCEPTED` = ready to transact, `NEEDS_INFO` = more fields required, `PROCESSING` = under review | **Required fields:** | Field | Type | Description | | --------------- | ------ | ---------------------- | | `first_name` | string | Customer's given name | | `last_name` | string | Customer's family name | | `email_address` | string | Valid email address | **Optional fields (improve approval speed):** | Field | Type | Example | | ---------------------- | ------ | ------------------------------------- | | `birth_date` | string | `"1990-05-15"` (YYYY-MM-DD) | | `address` | string | `"123 Main Street"` | | `city` | string | `"Accra"` | | `state_or_province` | string | `"Greater Accra"` | | `postal_code` | string | `"00233"` | | `address_country_code` | string | `"GHA"` (ISO 3166-1 alpha-3) | | `mobile_number` | string | `"+233244123456"` (with country code) | #### Step 2: Check Customer Status [#step-2-check-customer-status] If the status is `PROCESSING` or `NEEDS_INFO`, poll this endpoint: ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/widget/customer/6a3ce6751dbd5c0082a8d1b5' \ --header 'X-API-Key: sv_secret_' ``` **Response (200):** ```json { "data": { "id": "6a3ce6751dbd5c0082a8d1b5", "status": "ACCEPTED", "provided_fields": { "first_name": { "type": "string", "description": "Given or first name", "status": "ACCEPTED" }, "last_name": { "type": "string", "description": "Family or last name", "status": "ACCEPTED" }, "email_address": { "type": "string", "description": "Email address", "status": "ACCEPTED" }, "birth_date": { "type": "date", "description": "Date of birth (YYYY-MM-DD)", "status": "ACCEPTED" } } } } ``` **If status is `NEEDS_INFO`**, the response includes a `fields` object listing what's still required: ```json { "data": { "id": "6a3ce6751dbd5c0082a8d1b5", "status": "NEEDS_INFO", "fields": { "email_address": { "type": "string", "description": "Email address" }, "last_name": { "type": "string", "description": "Family or last name" } } } } ``` To provide the missing fields, call `POST /api/v1/widget/customer` again with the same data plus the missing fields — the customer record will be updated (idempotent). #### Step 3: Delete a Customer [#step-3-delete-a-customer] If a customer requests data deletion or you need to remove them: ```bash curl --location --request DELETE 'https://widget-backend.seevcash.com/api/v1/widget/customer/6a3ce6751dbd5c0082a8d1b5' \ --header 'X-API-Key: sv_secret_' ``` **Response (200):** ```json { "data": { "deleted": true } } ``` This removes the customer from both the SeevCash anchor and the widget database. The customer ID can no longer be used. #### Step 4: Use the Customer ID [#step-4-use-the-customer-id] Once your customer has `status: "ACCEPTED"`, use their ID as `user_id` everywhere: ```bash # Initialize session curl -X POST 'https://widget-backend.seevcash.com/api/v1/widget/session' \ --header 'X-API-Key: sv_secret_' \ --header 'Content-Type: application/json' \ --data '{ "user_id": "6a3ce6751dbd5c0082a8d1b5" }' # Create orders, interactive flows, etc. — all use the same customer ID ``` *** ### Option B: Anchor SEP-12 Direct Flow [#option-b-anchor-sep-12-direct-flow] For developers who want direct control over the Stellar anchor's KYC protocol. You interact with the anchor at `https://anchor.seevcash.com` using SEP-10 authentication and SEP-12 customer management. #### Prerequisites [#prerequisites] * A Stellar keypair (your merchant's keypair from registration, or any funded Stellar account) * Understanding of [SEP-10](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md) (authentication) and [SEP-12](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0012.md) (KYC) #### Step 1: SEP-10 Authentication [#step-1-sep-10-authentication] Get a challenge transaction and sign it with your Stellar secret key: ```bash # Get challenge curl 'https://anchor.seevcash.com/auth?account=GABC...YOUR_STELLAR_PUBLIC_KEY' # Response: { "transaction": "", "network_passphrase": "..." } # Sign the challenge with your secret key and submit: curl -X POST 'https://anchor.seevcash.com/auth' \ --header 'Content-Type: application/json' \ --data '{ "transaction": "" }' # Response: { "token": "eyJhbG..." } ``` The token is valid for **15 minutes**. #### Step 2: Create Customer (PUT /customer) [#step-2-create-customer-put-customer] ```bash curl --location --request PUT 'https://anchor.seevcash.com/customer' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data-raw '{ "first_name": "Patrick", "last_name": "Oduro", "email_address": "patrick@example.com", "birth_date": "1990-05-15", "address": "123 Main Street", "city": "Accra", "address_country_code": "GHA", "mobile_number": "+233244123456" }' ``` **Response (202):** ```json { "id": "6a3ce6751dbd5c0082a8d1b5" } ``` > Per SEP-12, `PUT /customer` only returns the customer ID. Query status with `GET /customer`. #### Step 3: Check Status (GET /customer) [#step-3-check-status-get-customer] ```bash curl 'https://anchor.seevcash.com/customer?id=6a3ce6751dbd5c0082a8d1b5' \ --header 'Authorization: Bearer ' ``` **Response (200):** ```json { "id": "6a3ce6751dbd5c0082a8d1b5", "status": "ACCEPTED", "provided_fields": { "first_name": { "type": "string", "description": "Given or first name", "status": "ACCEPTED" }, "last_name": { "type": "string", "description": "Family or last name", "status": "ACCEPTED" }, "email_address": { "type": "string", "description": "Email address", "status": "ACCEPTED" } } } ``` #### Step 4: Use the Customer ID in Widget [#step-4-use-the-customer-id-in-widget] The customer ID from the anchor can be used directly in the widget APIs: ```bash curl -X POST 'https://widget-backend.seevcash.com/api/v1/widget/session' \ --header 'X-API-Key: sv_secret_' \ --header 'Content-Type: application/json' \ --data '{ "user_id": "6a3ce6751dbd5c0082a8d1b5" }' ``` #### SEP-12 Customer Statuses [#sep-12-customer-statuses] | Status | Meaning | Action | | ------------ | ------------------------------------ | -------------------------------------------- | | `ACCEPTED` | KYC approved — customer can transact | ✅ Proceed | | `PROCESSING` | Under review by compliance | ⏳ Poll periodically | | `NEEDS_INFO` | Missing required information | Submit additional fields via `PUT /customer` | | `REJECTED` | KYC permanently denied | ❌ Customer cannot transact | #### SEP-12 Field Statuses (per field) [#sep-12-field-statuses-per-field] | Status | Meaning | | ----------------------- | ---------------------------------------------- | | `ACCEPTED` | Field verified and approved | | `PROCESSING` | Field under review | | `REJECTED` | Field rejected (see `error` for reason) | | `VERIFICATION_REQUIRED` | Field needs verification (e.g., OTP for phone) | #### Delete Customer (SEP-12) [#delete-customer-sep-12] ```bash curl --location --request DELETE 'https://anchor.seevcash.com/customer/6a3ce6751dbd5c0082a8d1b5' \ --header 'Authorization: Bearer ' ``` **Response (200):** ```json { "message": "customer deleted" } ``` *** ### Comparing the Two Flows [#comparing-the-two-flows] | | Widget Customer API | Anchor SEP-12 Direct | | ------------------ | ------------------------------------- | ------------------------------------- | | **Auth** | API Key (`X-API-Key`) | SEP-10 JWT (Stellar keypair signing) | | **Complexity** | Simple REST calls | Requires Stellar SDK & key management | | **Endpoint** | `widget-backend.seevcash.com` | `anchor.seevcash.com` | | **Customer ID** | Same format, interchangeable | Same format, interchangeable | | **Best for** | Web/mobile apps, fintech integrations | Stellar wallets, DeFi protocols | | **KYC handled by** | Widget (automatic) | You (manual SEP-10 → SEP-12 flow) | > 💡 **Both flows produce the same customer ID format.** A customer created via Option A can be queried via Option B and vice versa. *** ### Anchor Integration (Direct SEP-24 Flow) [#anchor-integration-direct-sep-24-flow] For merchants who want **programmatic control** over the payment flow — building custom UIs, triggering withdrawals from their backend, or integrating into existing apps — you can interact directly with the SeevCash Stellar Anchor using the Stellar Wallet SDK. *** ## Deposit & Withdraw (Interactive URLs) [#deposit--withdraw-interactive-urls] After completing [Customer KYC](#customer-kyc-verification), you can initiate deposit (buy USDC) and withdraw (sell USDC) flows. There are two approaches: | Approach | Auth | Best For | | ---------------------------- | --------------------------- | --------------------------------------------- | | **Widget API** (recommended) | API Key (`X-API-Key`) | Simple server-to-server integration | | **Anchor SEP-24 Direct** | SEP-10 JWT + session fields | Stellar-native wallets, full protocol control | Both return an **interactive URL** that you open in an iframe or popup for the customer to complete the payment. *** ### Option A: Widget Interactive API (Recommended) [#option-a-widget-interactive-api-recommended] The widget handles SEP-10 authentication, session management, and anchor communication. You just need your API keys and a verified customer ID. #### Prerequisites [#prerequisites-1] | Credential | How to Get | Example | | ---------------- | -------------------------------------------------- | -------------------------- | | `sv_secret_` key | `POST /merchant/apikeys` with `key_type: "secret"` | `sv_secret_4a95a7cc...` | | `sv_public_` key | `POST /merchant/apikeys` with `key_type: "public"` | `sv_public_61f8ac8b...` | | Customer ID | `POST /widget/customer` (must be `ACCEPTED`) | `6a3cec4b21ea9c49b5d34534` | > ⚠️ The **secret key** goes in the `X-API-Key` header (server-to-server). The **public key** goes in the request body `api_key` field (used to generate the widget token). #### Withdraw (Sell USDC → Receive Mobile Money) [#withdraw-sell-usdc--receive-mobile-money] Customer sends USDC to the anchor, receives GHS in their Mobile Money wallet. ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/widget/withdraw/interactive' \ --header 'X-API-Key: sv_secret_4a95a7cc50961ec5f0b6329c2b5aefb767a68ee3cf5b5133344a18549cad1d42' \ --header 'Content-Type: application/json' \ --data '{ "asset_code": "USDC", "user_id": "6a3cec4b21ea9c49b5d34534", "api_key": "sv_public_61f8ac8b75e8a55a16cdea49d2eff4392cf557b9c22df24ecca0eb07de2cdaba", "onchain_amount": 1 }' ``` **Request fields:** | Field | Type | Required | Description | | ---------------- | ------ | -------- | ------------------------------------------------------------------------- | | `asset_code` | string | ✅ | Always `"USDC"` | | `user_id` | string | ✅ | SEP-12 customer ID (must be `ACCEPTED`) | | `api_key` | string | ✅ | Your **public** API key (`sv_public_...`) | | `onchain_amount` | number | Optional | Amount of USDC to sell. If omitted, customer enters in the interactive UI | **Response (200):** ```json { "data": { "url": "https://exchange.seevcash.com/interactive?token=eyJhbG...", "id": "05c6ce07-18c8-48f8-b7b1-e5d66b735d3d", "type": "interactive_customer_info_needed" }, "meta": { "request_id": "d3fb956f-62d9-4e62-8b59-4c26e48674f6", "timestamp": "2026-06-25T09:26:16.375369413Z" } } ``` #### Deposit (Buy USDC with Mobile Money) [#deposit-buy-usdc-with-mobile-money] Customer pays with Mobile Money, receives USDC in their Stellar wallet. ```bash curl --location 'https://widget-backend.seevcash.com/api/v1/widget/deposit/interactive' \ --header 'X-API-Key: sv_secret_4a95a7cc50961ec5f0b6329c2b5aefb767a68ee3cf5b5133344a18549cad1d42' \ --header 'Content-Type: application/json' \ --data '{ "asset_code": "USDC", "user_id": "6a3cec4b21ea9c49b5d34534", "api_key": "sv_public_f69d93fb31adcb64d9f85d823be4236048e9bdc37bca4d5950182aa6d9b619fb", "fiat_amount": 13, "destination_address": "GCGES3SODXJX6RYF2ATRQIA5GLWDXTEXR6QWX3JERVCDY4EIFEMFB55O" }' ``` **Request fields:** | Field | Type | Required | Description | | --------------------- | ------ | -------- | ------------------------------------------------------------------------- | | `asset_code` | string | ✅ | Always `"USDC"` | | `user_id` | string | ✅ | SEP-12 customer ID (must be `ACCEPTED`) | | `api_key` | string | ✅ | Your **public** API key (`sv_public_...`) | | `fiat_amount` | number | Optional | Amount in GHS to spend. If omitted, customer enters in the interactive UI | | `destination_address` | string | Optional | Stellar address to receive USDC. If omitted, customer enters in the UI | **Response (200):** ```json { "data": { "url": "https://exchange.seevcash.com/interactive?token=eyJhbG...", "id": "fa80ebf4-b431-42ce-a255-e3680e221e09", "type": "interactive_customer_info_needed" }, "meta": { "request_id": "09193866-b809-4834-a9d8-682bf44be9ac", "timestamp": "2026-06-25T09:27:03.127272637Z" } } ``` #### Response Fields [#response-fields] | Field | Description | | ------ | ---------------------------------------------------------------------- | | `url` | Interactive URL — open in iframe, popup, or redirect the customer here | | `id` | Transaction ID — use to track status via `GET /transaction?id=...` | | `type` | Always `"interactive_customer_info_needed"` | #### What Happens After Getting the URL [#what-happens-after-getting-the-url] 1. **Open the `url`** in an iframe, popup, or full-page redirect 2. Customer selects their Mobile Money provider (MTN, Vodafone, AirtelTigo) 3. Customer enters their phone number and confirms payment 4. For **deposits**: Customer approves the MoMo prompt → receives USDC (1–4 min) 5. For **withdrawals**: Customer sees anchor's Stellar address + memo → sends USDC → receives GHS (1–5 min) 6. You receive a webhook when the transaction completes *** ### Option B: Anchor SEP-24 Direct Flow [#option-b-anchor-sep-24-direct-flow] For developers building Stellar-native wallets or who need full protocol control. You interact directly with the anchor at `https://anchor.seevcash.com`. > 💡 **We recommend using the [Stellar Wallet SDK](https://github.com/nickmaxwell10/typescript-wallet-sdk)** for SEP-24 integration. It handles SEP-10 authentication, token management, and transaction tracking automatically. #### Prerequisites [#prerequisites-2] * A Stellar keypair (your merchant's keypair from registration) * A widget session (`POST /api/v1/widget/session`) * A verified customer ID (SEP-12 `ACCEPTED`) #### Install the Stellar Wallet SDK [#install-the-stellar-wallet-sdk] ```bash npm install @stellar/typescript-wallet-sdk @stellar/stellar-sdk ``` #### Complete Flow with SDK (Recommended) [#complete-flow-with-sdk-recommended] ```typescript import { Wallet, SigningKeypair, StellarConfiguration, } from '@stellar/typescript-wallet-sdk'; const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; async function initiateWithdraw( signerSecret: string, // Your merchant's Stellar secret key merchantId: string, // From merchant registration sessionId: string, // From POST /widget/session sessionToken: string, // From POST /widget/session apiKey: string, // Your public key (sv_public_...) userId: string, // SEP-12 customer ID (ACCEPTED) ) { const accountKp = SigningKeypair.fromSecret(signerSecret); const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.MainNet(), }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); // Step 1: SEP-10 Authentication (SDK handles challenge signing) const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Step 2: SEP-24 Withdraw const sep24 = await anchor.sep24(); const { url, id, type } = await sep24.withdraw({ assetCode: 'USDC', authToken, extraFields: { merchant_id: merchantId, session_id: sessionId, session_token: sessionToken, api_key: apiKey, user_id: userId, }, }); // url → open in iframe/popup for customer // id → transaction ID for tracking return { url, id, type }; } async function initiateDeposit( signerSecret: string, merchantId: string, sessionId: string, sessionToken: string, apiKey: string, userId: string, stellarAccount: string, // Customer's Stellar address to receive USDC ) { const accountKp = SigningKeypair.fromSecret(signerSecret); const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.MainNet(), }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const sep24 = await anchor.sep24(); const { url, id, type } = await sep24.deposit({ assetCode: 'USDC', authToken, destinationAccount: stellarAccount, extraFields: { merchant_id: merchantId, session_id: sessionId, session_token: sessionToken, api_key: apiKey, user_id: userId, }, }); return { url, id, type }; } ``` #### Track Transaction Status with SDK [#track-transaction-status-with-sdk] ```typescript async function checkTransaction(signerSecret: string, transactionId: string) { const accountKp = SigningKeypair.fromSecret(signerSecret); const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.MainNet() }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const sep24 = await anchor.sep24(); const tx = await sep24.getTransactionBy({ authToken, id: transactionId }); console.log(tx.status); // "completed", "pending_user_transfer_start", etc. return tx; } ``` #### Required `extraFields` [#required-extrafields] All five fields below are **required for a production integration** and must be sent on every deposit and withdraw request. | Field | Type | Required | Description | | --------------- | ------ | ---------- | ----------------------------------------- | | `merchant_id` | string | ✅ Required | Your merchant ID from registration | | `session_id` | string | ✅ Required | From `POST /widget/session` response | | `session_token` | string | ✅ Required | From `POST /widget/session` response | | `api_key` | string | ✅ Required | Your **public** API key (`sv_public_...`) | | `user_id` | string | ✅ Required | SEP-12 customer ID (must be `ACCEPTED`) | > ⚠️ Omitting any of these returns `400 Bad Request` (e.g. `api_key is required`) and no transaction is created. While experimenting against the testnet host they may be accepted when absent, but do not rely on that — build and test with all five present so your integration behaves identically in production. #### Getting Session Credentials [#getting-session-credentials] Before calling the anchor, create a widget session: ```bash curl -X POST 'https://widget-backend.seevcash.com/api/v1/widget/session' \ --header 'X-API-Key: sv_secret_' \ --header 'Content-Type: application/json' \ --data '{ "user_id": "6a3cec4b21ea9c49b5d34534" }' # Response: # { "data": { "session_id": "...", "session_token": "...", "merchant_id": "...", "expires_at": "..." } } ``` *** #### Advanced: Raw HTTP Calls (without SDK) [#advanced-raw-http-calls-without-sdk] If you can't use the SDK, here are the raw endpoints. You'll need to handle SEP-10 authentication manually. ##### SEP-10 Authentication [#sep-10-authentication] ```bash # 1. Get challenge curl 'https://anchor.seevcash.com/auth?account=' # Response: { "transaction": "", "network_passphrase": "..." } # 2. Sign the XDR with your secret key and submit curl -X POST 'https://anchor.seevcash.com/auth' \ --header 'Content-Type: application/json' \ --data '{ "transaction": "" }' # Response: { "token": "eyJhbG..." } (valid 15 minutes) ``` ##### Deposit Interactive [#deposit-interactive] ```bash curl --location 'https://anchor.seevcash.com/transactions/deposit/interactive' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data-raw '{ "asset_code": "USDC", "account": "GCGES3SODXJX6RYF2ATRQIA5GLWDXTEXR6QWX3JERVCDY4EIFEMFB55O", "amount": "13", "merchant_id": "d92e377f-2456-4186-b6a5-cbce930a6219", "session_id": "5900c14c-a19f-49df-abbe-666cef7d5593", "session_token": "d345545a-951d-4ebe-b7e3-d7050a120436", "api_key": "sv_public_f69d93fb31adcb64d9f85d823be4236048e9bdc37bca4d5950182aa6d9b619fb", "user_id": "6a3cec4b21ea9c49b5d34534" }' ``` ##### Withdraw Interactive [#withdraw-interactive] ```bash curl --location 'https://anchor.seevcash.com/transactions/withdraw/interactive' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data-raw '{ "asset_code": "USDC", "account": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "amount": "1", "merchant_id": "d92e377f-2456-4186-b6a5-cbce930a6219", "session_id": "e70d3556-4d6b-47fe-8d33-bacd17c9cab1", "session_token": "cc7fad85-b9ce-48de-bdfb-89d3d87ab9fe", "api_key": "sv_public_61f8ac8b75e8a55a16cdea49d2eff4392cf557b9c22df24ecca0eb07de2cdaba", "user_id": "6a3cec4b21ea9c49b5d34534" }' ``` ##### Request Fields (Both Endpoints) [#request-fields-both-endpoints] | Field | Type | Required | Description | | --------------- | ------ | ---------- | --------------------------------------------- | | `asset_code` | string | ✅ Required | `"USDC"` | | `merchant_id` | string | ✅ Required | Your merchant ID | | `session_id` | string | ✅ Required | From `POST /widget/session` | | `session_token` | string | ✅ Required | From `POST /widget/session` | | `api_key` | string | ✅ Required | Your public API key (`sv_public_...`) | | `user_id` | string | ✅ Required | SEP-12 customer ID (must be `ACCEPTED`) | | `account` | string | Optional | Stellar public key. Defaults to JWT's account | | `amount` | string | Optional | Amount (fiat for deposit, USDC for withdraw) | | `quote_id` | string | Optional | From SEP-38 quote (for guaranteed rates) | | `lang` | string | Optional | Language code (default: `"en"`) | ##### Response [#response] ```json { "type": "interactive_customer_info_needed", "url": "https://exchange.seevcash.com/interactive?token=eyJhbG...", "id": "05c6ce07-18c8-48f8-b7b1-e5d66b735d3d" } ``` ##### Track Transaction [#track-transaction] ```bash curl 'https://anchor.seevcash.com/transaction?id=' \ --header 'Authorization: Bearer ' ``` #### Transaction Status Flow [#transaction-status-flow] ``` Deposit: incomplete → pending_user_transfer_start → pending_anchor → pending_stellar → completed Withdraw: incomplete → pending_user_transfer_start → pending_external → pending_payout_confirmation → completed ``` | Status | Meaning | What's Happening | | ----------------------------- | -------------------------------- | ----------------------------------------------------------------- | | `incomplete` | Interactive UI not yet completed | Customer hasn't finished the form | | `pending_user_transfer_start` | Waiting for customer action | Deposit: awaiting MoMo approval. Withdraw: awaiting USDC transfer | | `pending_anchor` | Anchor processing | Fiat received, preparing crypto delivery | | `pending_stellar` | Blockchain transaction submitted | USDC sent, awaiting confirmation | | `pending_external` | Crypto received by anchor | USDC received, initiating fiat payout | | `pending_payout_confirmation` | Fiat payout in progress | Mobile Money disbursement initiated | | `completed` | Done ✅ | Transaction finished successfully | | `expired` | Timed out | Customer didn't complete within window | | `error` | Failed | See `message` field for details | #### Network Configuration [#network-configuration] | Environment | Widget Backend | Anchor Domain | Network | | ----------- | ----------------------------- | --------------------- | --------------- | | Production | `widget-backend.seevcash.com` | `anchor.seevcash.com` | Stellar Mainnet | | Testnet | `widget-backend.seevcash.com` | `anchor.seevcash.com` | Stellar Testnet | *** ### Comparing Widget vs Anchor Direct for Interactive Flows [#comparing-widget-vs-anchor-direct-for-interactive-flows] | | Widget API | Anchor SEP-24 (SDK) | | ------------------------- | ---------------------------------- | ------------------------------------------- | | **Endpoint** | `POST /widget/deposit/interactive` | SDK: `sep24.deposit()` / `sep24.withdraw()` | | **Auth** | `X-API-Key: sv_secret_...` | SEP-10 (SDK handles automatically) | | **Session mgmt** | Automatic | Manual (create session, pass fields) | | **Language** | Any (REST API) | TypeScript/JavaScript | | **When to use** | Server-to-server, any language | Stellar wallet apps, JS/TS projects | | **Same interactive URL?** | ✅ Yes | ✅ Yes | | **Same transaction ID?** | ✅ Yes | ✅ Yes | > 💡 Both approaches generate the **same interactive URL** pointing to `exchange.seevcash.com`. The customer experience is identical regardless of which method you use. *** ### Get Widget Config [#get-widget-config] ``` GET /api/v1/widget/config X-API-Key: ``` **Response (200):** ```json { "data": { "merchant_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "theme": { "primary_color": "#4F46E5", "border_radius": "8px" }, "allowed_coins": ["USDC"], "redirect_url": "https://mystore.com/payment-complete" } } ``` *** ### Update Widget Config [#update-widget-config] ``` PUT /api/v1/merchant/widget-config Authorization: Bearer Content-Type: application/json ``` ```json { "theme": { "primary_color": "#10B981", "border_radius": "12px" }, "allowed_coins": ["USDC"], "redirect_url": "https://mystore.com/thank-you" } ``` *** ### List Merchant Orders [#list-merchant-orders] ``` GET /api/v1/merchant/orders?limit=20&cursor= Authorization: Bearer ``` **Response (200):** ```json { "data": [ { "id": "ord_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "completed", "fiat_currency": "GHS", "fiat_amount": 100.0, "crypto_currency": "USDC", "crypto_amount": 6.45, "payment_method": "mobile_money", "created_at": "2026-05-13T10:30:00Z" } ], "next_cursor": "ord_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "has_more": false } ``` *** ### Other Merchant Dashboard Endpoints [#other-merchant-dashboard-endpoints] | Endpoint | Method | Description | | ------------------------------ | ------ | ----------------------------------------- | | `/api/v1/merchant/profile` | GET | View your merchant profile | | `/api/v1/merchant/apikeys/:id` | DELETE | Revoke an API key | | `/api/v1/merchant/kyb/submit` | POST | Submit KYB (Know Your Business) documents | | `/api/v1/merchant/kyb/status` | GET | Check KYB verification status | All require `Authorization: Bearer `. *** ## Required Headers [#required-headers] | Header | Used In | Description | | ------------------- | --------------------------- | ---------------------------------- | | `X-API-Key` | Widget endpoints | Your merchant API key | | `X-Session-Id` | Session-protected endpoints | From `/widget/session` response | | `X-Session-Token` | Session-protected endpoints | From `/widget/session` response | | `X-Idempotency-Key` | Order creation | Unique UUID to prevent duplicates | | `Authorization` | Dashboard endpoints | `Bearer ` from login | | `Content-Type` | All POST/PUT | `application/json` | *** ## Error Responses [#error-responses] All errors follow this format: ```json { "error": { "code": "VALIDATION_ERROR", "message": "fiat_amount must be greater than 0" } } ``` | Code | HTTP Status | Meaning | | -------------------- | ----------- | --------------------------------------------------- | | `VALIDATION_ERROR` | 400 | Request body failed validation | | `MISSING_HEADER` | 400 | Required header missing (e.g., `X-Idempotency-Key`) | | `UNAUTHORIZED` | 401 | Invalid or expired API key / session / token | | `FORBIDDEN` | 403 | Domain not in allowed list | | `NOT_FOUND` | 404 | Order or resource not found | | `DUPLICATE_ORDER` | 409 | Idempotency key already used | | `PHONE_NOT_VERIFIED` | 422 | Phone number hasn't been OTP-verified | | `QUOTE_EXPIRED` | 422 | Quote expired, request a new one | | `RATE_LIMITED` | 429 | Too many requests | | `INTERNAL_ERROR` | 500 | Something went wrong on our end | *** ## Transaction Statuses [#transaction-statuses] | Status | Meaning | | ----------------------------- | ----------------------------------------------- | | `pending_user_transfer_start` | Waiting for customer to complete payment | | `pending_anchor` | Fiat received, crypto delivery in progress | | `pending_stellar` | Crypto sent, awaiting blockchain confirmation | | `pending_external` | Crypto received, fiat payout initiated | | `pending_payout_confirmation` | Fiat payout in progress | | `completed` | Transaction finished successfully | | `failed` | Something went wrong | | `expired` | Customer didn't complete within the time window | *** ## Webhooks [#webhooks] Configure a webhook URL in your merchant dashboard for server-side notifications. ### Payload Example: [#payload-example] ```json { "event": "order.completed", "order_id": "ord_abc123", "type": "onramp", "fiat_amount": "100.00", "fiat_currency": "GHS", "crypto_amount": "6.45", "crypto_currency": "USDC", "destination_address": "GABCD...XYZ", "timestamp": "2026-05-13T10:30:00Z" } ``` ### Security Headers: [#security-headers] | Header | Description | | --------------------- | ----------------------------------------- | | `X-Webhook-Signature` | HMAC-SHA256 signature of the payload body | | `X-Webhook-Timestamp` | Unix timestamp when sent | Verify using your webhook secret (found in dashboard). ### Events: [#events] | Event | Description | | ----------------- | ---------------------------------------------------- | | `order.completed` | Crypto delivered (onramp) or fiat paid out (offramp) | | `order.failed` | Payment failed or declined | | `order.expired` | Payment window timed out | ### Retry Policy: [#retry-policy] If your endpoint returns non-2xx, we retry: 1. Immediate → 2. 10 seconds → 3. 1 minute → 4. 1 hour → 5. 4 hours After 5 failed attempts, delivery is marked exhausted. *** ## Supported Payment Methods [#supported-payment-methods] | Method | Buy | Sell | Speed | | ---------------- | :-: | :--: | ----------- | | MTN Mobile Money | ✅ | ✅ | 1–4 min | | Vodafone Cash | ✅ | ✅ | 1–4 min | | AirtelTigo Money | ✅ | ✅ | 1–4 min | | Bank Transfer | 🚧 | 🚧 | Coming Soon | *** ## Supported Assets [#supported-assets] | Asset | Network | Description | | ----- | ------- | ----------------------------------------- | | USDC | Stellar | US Dollar-pegged stablecoin (1 USDC ≈ $1) | *** ## Fees [#fees] | Fee Type | Description | | --------------- | ------------------------------------------------------------------ | | Platform fee | Percentage of transaction (your `fee_base_bps` + `fee_markup_bps`) | | Network fee | Stellar transaction fee (fractions of a cent) | | Exchange spread | Built into the quoted rate | All fees are shown to the customer before they confirm. *** ## Security [#security] * All data in transit uses **TLS/HTTPS** * The widget handles all sensitive payment details — nothing touches your servers * Customer phone numbers are **OTP-verified** before any payment * **Idempotency keys** prevent duplicate charges * API keys can be scoped to specific **domains and IP addresses** * Webhooks are **HMAC-SHA256 signed** for verification *** ## API Key Best Practices [#api-key-best-practices] * Use `public` keys for frontend widget, `secret` keys for server-to-server * Restrict keys to your **allowed domains** and IPs * Never expose `secret` keys in client-side code * Use separate keys for **development and production** * Rotate keys periodically *** ## Testing [#testing] Use test mode during development: * Stellar testnet (no real money moves) * Mobile Money prompts are simulated * All flows work identically to production Switch to your live API key when ready to go live. *** ## FAQ [#faq] **Q: Do I need to understand blockchain?** A: No. Add the script tag, receive webhooks. We handle everything else. **Q: What if a payment fails?** A: No crypto is sent, no money deducted. Customer can retry. **Q: How fast are transactions?** A: 1–4 minutes for buying, 1–5 minutes for selling. **Q: Can I customize the widget?** A: Yes — theme colors, allowed coins, and redirect URLs via the widget config API. **Q: Minimum/maximum amounts?** A: Configurable per merchant during onboarding. **Q: What if a customer doesn't have a Stellar wallet?** A: They need one to receive USDC. Recommend Lobstr or StellarX. *** ## Support [#support] * **Email:** [merchants@seevcash.com](mailto:merchants@seevcash.com) * **Dashboard:** [dashboard.seevcash.com](https://seevplus.com) * **Docs:** [docs.seevcash.com](https://docs.seevcash.com) *** ## Quick Start Checklist [#quick-start-checklist] * [ ] Register merchant account * [ ] Log in and create API key (store it securely!) * [ ] Create a customer via `/widget/customer` (KYC verification) * [ ] Confirm customer status is `ACCEPTED` * [ ] Initialize widget session with the customer ID as `user_id` * [ ] Test a buy and sell flow * [ ] Configure webhook endpoint * [ ] Verify webhook signatures * [ ] Switch to live API key * [ ] Go live 🚀 # Troubleshooting Seev Plus (/docs/troubleshooting) Most Seev Plus failures trace back to the active organization, the verification state, the status of the record you are acting on, or a prerequisite that has not been met. Work through the section that matches what you are seeing before you retry the action or recreate the record. ## Run these checks first [#run-these-checks-first] 1. Confirm the organization shown at the bottom of the sidebar. 2. Refresh the page after changing organizations. 3. Check the verification banner and follow its action if one is shown. 4. Confirm the record is still active and has not been paid, cancelled, or expired. 5. Check that the currency and source wallet match the action. ## Fix wrong business data [#fix-wrong-business-data] Open the organization selector and choose the correct organization. Stores, customers, invoices, payment links, wallets, verification, API keys, and PINs are scoped per organization and do not carry over between them, so a record created under one organization is invisible from another. If the correct organization is already selected, refresh the page once. Do not recreate a record until you have confirmed it is genuinely missing, because recreating it under the wrong organization is what usually turns one missing record into two. ## Generate a checkout link [#generate-a-checkout-link] Live invoice and payment-link checkout requires approved organization verification. If verification is not yet approved, resolve that first using the section below. Once verification is approved, confirm each of the following: * The invoice has a customer and at least one item. * The invoice or payment link is not paid or cancelled. * An earlier checkout link has not already completed payment. * The amount is greater than zero and uses a supported currency. ## Clear a blocked or rejected verification [#clear-a-blocked-or-rejected-verification] Act according to the state shown on the verification banner. * **Draft:** Continue the remaining modules shown by the verification app. * **Ready for review:** No action is required unless Seev requests a change. * **Rejected:** Read the reason in the Dashboard banner, choose **Update verification**, edit the existing prefilled information, and submit again. * **Approved:** Refresh Seev Plus if a restricted action does not unlock immediately. Use the business or contractor email associated with the verification profile. One active verification profile cannot be shared across unrelated emails, so signing in with a different address starts a separate profile rather than continuing the existing one. ## Enable a storefront product [#enable-a-storefront-product] A public storefront product needs at least one image. Click or drop an image onto the empty thumbnail, wait for the upload to finish, then enable the product. Enabling before the upload completes leaves the product in its previous state. Also confirm the product is active and has available stock. Disabled products do not appear in POS or in the public store. ## Resolve a payment link with no copy or QR action [#resolve-a-payment-link-with-no-copy-or-qr-action] Copy, QR, and active checkout actions disappear once a link is paid, cancelled, expired, or otherwise no longer payable. The link may still be visible as a receipt record, which is why it looks present but inert. Create a new payment request only when the underlying sale genuinely needs another payment. A second link against an already-paid sale is a double charge waiting to happen. ## Resolve a pending payment [#resolve-a-pending-payment] Pending means Seev has not received a final result yet. It does not mean the payment failed, so do not ask the customer to pay again immediately. 1. Wait for the customer's mobile-money or payment confirmation. 2. Refresh the transaction or the source record. 3. Check whether a final status or webhook event has arrived. 4. Use the payment reference when comparing Dashboard records against customer records. If you are integrating over the API, verify the session server-side rather than waiting on the webhook alone. Automatic webhook delivery is a single attempt with no automatic redelivery, so a pending record can stay pending in your own database purely because one delivery failed. See [Work around single-attempt delivery](/docs/errors#work-around-single-attempt-delivery). ## Reconcile a balance that looks wrong [#reconcile-a-balance-that-looks-wrong] GHS, USDC, and other wallet balances are held separately. Changing the currency view switches which balance you are looking at; it does not convert or merge them. Confirm the selected organization, the selected wallet, the transaction status, and the currency before treating the number as wrong. A pending payment may be counted in the record but not yet available to withdraw, which accounts for most reported mismatches. ## Fix a transaction PIN that will not verify [#fix-a-transaction-pin-that-will-not-verify] PINs belong to organizations. A PIN set on another organization will not verify here, so confirm the active organization and use the PIN that belongs to it. Avoid repeated guesses. If the current PIN cannot be verified, use the available recovery path rather than creating another organization or another PIN, both of which leave you with more state to reconcile and do not restore access. If the PIN cannot be recovered, contact support. After you verify your identity against the organization, support resets the PIN to a placeholder for you to change from the dashboard. See [PIN guidance](/docs/accounts/pin). ## Save a payout schedule [#save-a-payout-schedule] Manual payouts do not require a destination. Scheduled payouts require both a valid selected destination and approved verification, so a schedule that will not save usually has one of those missing. Check that the destination details belong to the active organization and match its destination type. ## Fix a failing API request [#fix-a-failing-api-request] Confirm all of the following before changing your request body: * Developer terms have been accepted for the organization. * The key belongs to the correct product, which is Checkout API for checkout requests. * The key and the Dashboard are using the same sandbox or production environment. * Production verification is approved. * The secret key was copied when it was first shown and has not been rotated or deleted. * The request body and URL match the current [Checkout API guide](/docs/payments/checkout). Amounts are a common cause of a rejected body. They are expressed in the currency's smallest unit, so `10000` with currency `GHS` means GHS 100.00, and sending `100` for that same charge requests GHS 1.00. A key issued before the SeevPlus payment routes existed must be rotated once before it can authenticate against them, because only a one-way hash of newly issued secrets is stored. That failure returns `401` with a message ending `existing keys must be rotated once before using this endpoint`. Read the HTTP status together with the response body, then look the status up in [Errors](/docs/errors), which lists what each one means per route and whether it is safe to retry. Note that an inactive organization returns `400` rather than `403`, so a `400` is not always a malformed body. When you share a failure with support or a colleague, include the HTTP status, the response body, the transaction reference, and the `Idempotency-Key` you sent. Never share a secret key or a full customer payment payload. # Cards (/docs/accounts/cards) Cards are USD virtual cards issued to your team and funded from the organization's balances. Find them under **Manage** in the sidebar. Cards only appears in the sidebar when the organization's verification type is business. An organization verified as an individual does not see the item at all, which is a product limit rather than a missing permission. See [Organization Verification](/docs/organizations/verification). Access is also gated by permission. The relevant keys are listed here, since a teammate hitting a wall usually needs one of them rather than a different role. | Permission | Grants | | --------------------- | --------------------------------------------------- | | `card.read` | Seeing cards and their activity. | | `card.create` | Issuing a new card. | | `card.manage` | Locking, unlocking, cancelling, and setting limits. | | `card.fund` | Moving money onto a card. | | `card.details.reveal` | Revealing the card number, expiry, and CVV. | Owners and admins hold every permission. A member holds only what a custom role grants. See [Team Access](/docs/settings/team). ## Apply for card access [#apply-for-card-access] Before any card exists, the organization applies for card access through Seev's card partner. Accept the card agreements, then review the fee breakdown for your first card, which lists the creation fee, the initial deposit, the monthly maintenance fee, and the total debit. The amounts are fetched and shown on that screen; read them there rather than assuming figures from elsewhere. Nothing is charged at this point. The creation fee and initial deposit are debited only when you create your first card after approval. Select **Submit for review**. If the agreements cannot be recorded you see "Card agreements could not be accepted", and if the fee figures cannot be refreshed you see "Unable to refresh the fee breakdown"; in both cases nothing is submitted, so retry rather than assuming the application went through. After submission the page reports the partner's decision. | State | What it means | What to do | | ---------------------------- | --------------------------------------------------------------------- | ------------------------------------------------ | | Pending review | The application is submitted and awaiting review. | Nothing. You can keep using Seev Plus meanwhile. | | Additional review | The card partner is completing a further review. | Wait for the outcome. | | More information is required | The partner needs more information before it can approve. | Supply what the page asks for. | | Complete your verification | The partner needs an extra verification step from you. | Complete the step the page links to. | | Not approved | Card access was declined. | Contact support to understand the decision. | | Locked | The application is locked. | Contact support. | | Cancelled | The application was cancelled and will not be resubmitted on its own. | Contact support if you still want card access. | ## Issue a card [#issue-a-card] With the organization approved, open Cards and start a new card. You provide: | Field | Type | Required | Description | | --------------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Card name | Text | Yes | A label such as `Marketing spend`. Blank input is rejected with "Enter a card name". | | Spending limit | Number | Yes | Must parse as a number and cannot be negative, or you get "Enter a valid spending limit". | | Activation date | Date | Yes | The card is not usable before this date. | | End date | Date | Yes | Must be after the activation date, or you get "Choose valid card dates". Missing either date gives "Select an activation and end date". | | Assigned to | Team member | No | The cardholder. Leaving it unassigned issues the card to the organization. | Confirm with the organization's [transaction PIN](/docs/accounts/pin). Assigning a card to a teammate checks that person's cardholder record first. If their details are incomplete, the flow stops and asks for the missing cardholder information before the card can be issued rather than failing at the end. If the check itself cannot run you get "Unable to confirm this member's card readiness"; try again before assuming the person is ineligible. Attempting to create a card while the organization is not approved is refused with "Your organization must be approved for Cards first". ## Fund a card [#fund-a-card] Open the card and start a funding transfer. Money moves from the organization's USD balance, and the page shows that balance beside the source so you can see what is available. Enter an amount of at least 0.01, review the summary, and confirm. Funding runs through a review step and then a result. A failure states plainly that nothing was moved and your USD balance is unchanged, and gives the attempted amount with a reason. That reason is where to start; the usual causes are an insufficient USD balance, a card that is locked or cancelled, or a limit already reached. Do not resubmit a funding transfer while an earlier one is still settling. The balance updates as soon as the transfer settles. ## Lock or unlock a card [#lock-or-unlock-a-card] Locking stops new spending while keeping the card. Choose a reason from planned inactivity, card misplaced, suspicious charge, suspected fraud, or other. Choosing **Other** requires an explanation, and submitting without one shows "Explain the reason for this request". Confirm with the transaction PIN. Unlocking uses the same flow in reverse and also asks for the transaction PIN. Confirmation reads "Card locked" or "Card unlocked". Lock first whenever a card may be exposed. Locking is reversible and cancelling is not. ## Cancel a card [#cancel-a-card] Cancelling permanently disables the card and cannot be undone. Confirm with the transaction PIN; the dashboard states the irreversibility before you do. A cancelled card cannot be restored, reissued under the same number, or unlocked later. If you are acting on a card that is merely missing, lock it instead and cancel only once you are certain. ## Reveal card details [#reveal-card-details] Revealing the card number, expiry date, and CVV asks for a PIN first, and the revealed number can be copied. A wrong PIN shows an inline error and reveals nothing. Reveal details only when you are about to use them. Do not screenshot or paste a full card number into chat or a ticket, and lock the card if its details may have been seen by someone who should not have them. ## Set a card PIN [#set-a-card-pin] A card PIN is used at terminals and ATMs and is separate from the organization's transaction PIN. Choose four digits and re-enter them; a mismatch shows "Those PINs don't match. Try again." Unlike the transaction PIN, this flow rejects an obvious code such as four repeated digits or a plain sequence. Some card PIN changes finish with Seev's card partner rather than in the dashboard. When that happens the page says so and hands you a link to complete the step securely. ## Set a spending limit [#set-a-spending-limit] A spending limit declines payments above it and resets each period. Choose whether the limit applies per day or per month, enter the amount, and save. Confirmation reads "Spending limit updated". A card that starts declining unexpectedly is worth checking against its limit and its period before you treat the decline as a fault. ## Related guides [#related-guides] * [Accounts and Wallets](/docs/accounts) * [Transaction PIN](/docs/accounts/pin) * [Team Access](/docs/settings/team) * [Organization Verification](/docs/organizations/verification) # Accounts & Wallets (/docs/accounts) **Balances** in the sidebar is where you see and move money owned by the active organization. Switching organizations switches the balances, activity, receiving details, and payout eligibility on the page together, so confirm the organization in the selector before you act on anything you read here. The page needs the `wallet.read` permission. A teammate who cannot see Balances or Transactions in the sidebar is missing that permission rather than looking at an empty organization. See [Team Access](/docs/settings/team). ## Read the balance cards [#read-the-balance-cards] Two cards sit at the top of Balances. The Ghanaian cedi card shows the GHS balance with **Payout** and **Add** actions, and a caption saying whether the balance auto-settles daily or is paid out manually. That caption reflects the schedule saved in [Payout settings](/docs/settings/payouts). The US dollar card shows the USD balance. Its badge tells you what the balance can currently do: | Badge | Meaning | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | **USDC only** | The organization holds USDC and can send and spend it, but has no US receiving account. Bank rails stay locked until a US account is approved. | | **Account in review** | A US account application has been submitted and is being reviewed. | | No badge | A US account is active, so the balance also receives through bank transfer. | The card action reads **Set up** when there is no US account yet and **Details** once there is. A cedi balance is not a converted view of a dollar balance. Each card holds separate funds, and moving between them is a conversion rather than a display change. Balances shown on this page are dashboard figures in major currency units, so a GHS 100.00 balance reads as 100.00. API amounts use the currency's smallest unit, where `10000` means GHS 100.00. See [Payments](/docs/payments) for the API side. GHS is currently the only enabled fiat currency for organization wallets. Other currency codes exist in the system but are not enabled, so do not plan around them. ## Share receiving details [#share-receiving-details] Open the account details panel below the balance cards. When the organization has both a US account and USDC, a toggle switches between **US account details** and **USDC details**. With only USDC available the toggle is hidden and the panel shows USDC details directly, which is expected rather than a missing feature. US account details cover the account holder name, the account number, the account type, the bank routing number, the bank name, and the bank address. Copy them exactly as displayed. The dashboard states the following about the US account, and these are the terms your sender is subject to: * The account sends and receives USD by bank transfer over ACH, Wire, and SWIFT. * Receiving by ACH costs 0.8%, with a $2 minimum and a $10 maximum, and needs the ACH routing number. * USD payments can only be received from banks inside the United States. * Incoming payments take between one and three days depending on the scheme the sending bank uses. USDC details cover the network, the wallet address, and, for one of the two address formats, a Memo ID. The panel offers two USDC address formats. One requires the Memo ID to be included with the transfer, and funds sent to it without the memo do not credit correctly. The other is a unique address that needs no memo. Read which format you are copying before you send the details to a payer. ## Trace money through Activity [#trace-money-through-activity] The Activity card at the bottom of Balances is one feed covering balance movements, payments collected through Seev, and payouts. Filter it with All, Money in, or Payouts. Activity shows the most recent eight rows. Use **See all** to open the full Transactions page, which draws on the same source and adds a search box for a name, reference, or memo; tiles for money in, money out, fees paid, and pending; and filters for All, Money in, Money out, and Pending. Rows there are grouped by day. Selecting a row does one of three things: * A balance movement opens a transaction panel. * A payment opens the invoice, store order, or payment link behind it. * A payout opens its reconciliation view. See [Payout Schedules](/docs/settings/payouts) for what that view contains. Some payouts recorded before settlement IDs existed cannot be opened, and say on hover that details are unavailable for that older payout. This is a limit of the historical record, not a permissions problem. An empty feed says no activity yet and offers to send an invoice or show your receiving details. If you have filtered to Money in or Payouts and see "No money in yet." or "No payouts yet.", clear the filter before concluding that money is missing. ## Read a transaction panel [#read-a-transaction-panel] Opening a balance movement shows its amount and status, then these groups: | Group | Fields | | ------------------- | ---------------------------------------------------------------------------------------------- | | Amounts | Gross amount, fee amount, whether the fee was deducted, net amount, asset | | Tracking details | Deposit tracking ID, destination tracking ID | | On-chain details | Type, transaction hash, memo, from address, to address, deposit method, created at, updated at | | Source details | Source currency, developer fee | | Destination details | Destination blockchain, destination currency | Keep the reference or transaction hash until a movement reaches a final state. ## Understand transaction statuses [#understand-transaction-statuses] Statuses differ by the kind of transaction, so the same word does not appear everywhere. | Kind | Statuses | | ----------------------- | -------------------------------------------- | | Fiat wallet transaction | `pending`, `completed`, `failed`, `reversed` | | Stellar transaction | `pending`, `confirmed`, `failed` | `pending` means Seev does not have a final result yet, so repeating the action risks sending twice. `reversed` applies to fiat only and means a recorded movement was returned or undone. A Stellar transaction that succeeds reads `confirmed` rather than `completed`, which is not a different outcome. The combined feed groups many upstream wordings into successful, failed, and pending buckets, so a row's chip may not use the exact wording above. ## Move money out [#move-money-out] GHS and USD leave the account by different routes. Settled cedi funds go out on the organization's payout schedule, and each completed payout is written to the Activity feed on this page. USD supports a transfer you start yourself, through **Send** on the USD balance card. | Balance | How money leaves | Where to set it up | | ------- | ------------------------------------------------------------------------------------------- | ----------------------------------------- | | GHS | Automatic payouts on the organization's schedule. There is no send action on the cedi card. | [Payout settings](/docs/settings/payouts) | | USD | A transfer you start, to a bank account or a USDC wallet. | [Send money](/docs/payments/payouts) | Before confirming any outgoing movement: 1. Check that the correct organization is active. 2. Check the source balance and currency. 3. Check the recipient details against a statement or the recipient's own record. 4. Read the fees, rate, source amount, and destination amount on the review step. Withdrawals and transfers ask for the organization's [transaction PIN](/docs/accounts/pin). Yield movements and sensitive card actions do too. If a movement is rejected, the message names the cause. The usual causes are an insufficient available balance, a pending earlier movement that is not yet spendable, a currency that does not match the destination, or a missing transaction PIN. Read the message before retrying, and never resubmit a movement that is still pending. ## Receive the weekly summary [#receive-the-weekly-summary] The organization owner is emailed a summary each week when the organization had transaction activity. The email arrives Monday and covers the previous completed week, running Monday to Monday in UTC. It carries counts of total, completed, pending, and failed transactions, completed volume broken down by currency, and up to five of the most recent transactions. Only the owner receives it. It does not go to other members or to the alert email, and an organization with no transactions in the period is skipped rather than sent an empty summary. ## Protect account access [#protect-account-access] * Give each teammate their own membership and role instead of sharing a login. See [Team Access](/docs/settings/team). * Never share a transaction PIN, and note that an API key is not a transaction PIN. * Verify bank and mobile-money details before saving a destination. * Do not send stablecoins over an unsupported network or without a required memo. * Rotate an API key as soon as it may have been exposed. ## Related guides [#related-guides] * [Transaction PIN](/docs/accounts/pin) * [Virtual Accounts](/docs/accounts/virtual-accounts) * [Cards](/docs/accounts/cards) * [Yield](/docs/accounts/yield) * [Payout Schedules](/docs/settings/payouts) * [Troubleshooting](/docs/troubleshooting) # Transaction PIN (/docs/accounts/pin) A transaction PIN is a four-digit numeric code that confirms sensitive money and card actions. It belongs to the active organization rather than to you, so every member acting in that organization confirms with the same PIN, and one organization having a PIN says nothing about another. The PIN is separate from your login password and separate from the PIN you set on a card. ## Set the organization's PIN [#set-the-organizations-pin] The Dashboard shows a "Set up your transaction PIN" prompt when two conditions hold at once: 1. The organization's verification is either submitted for review or already approved. 2. No PIN has been set for that organization yet. If you do not see the prompt, one of those two is false. An organization still drafting its verification cannot set a PIN yet, and an organization that already has one has nothing to set. See [Organization Verification](/docs/organizations/verification) for moving verification forward. Select **Set up PIN**, enter four digits, then re-enter the same four digits on the second step. Entering a code that is not four digits shows "OTP must be 4 digits", and a mismatch on the second step shows "PINs do not match" without saving anything. The digits are masked as you type. Setting or changing a PIN needs the `organization.security.manage` permission. Owners and admins hold every permission; a member holds it only through a custom role that includes it. See [Team Access](/docs/settings/team). Seev stores the PIN as a one-way hash and cannot read it back to you or to support. Choose something you will remember, and do not rely on anyone at Seev being able to tell you what it is. ## Confirm an action with the PIN [#confirm-an-action-with-the-pin] When a sensitive action needs confirmation, Seev asks for a single four-digit entry and verifies it before continuing. These actions ask for it: * Wallet withdrawals and transfers, including mobile-money withdrawals * Yield deposits and withdrawals * Locking, unlocking, and cancelling a card * Revealing a card's protected details Enter the PIN belonging to the organization currently shown in the sidebar. If you recently switched organizations, the previous organization's PIN will not verify, and the failure looks the same as a wrong PIN. Avoid repeated guesses. A rejected PIN leaves the underlying action untouched, so nothing has moved and there is no partial transfer to unwind. ## Change the PIN [#change-the-pin] Changing the PIN asks for the current PIN, a new four-digit PIN, and a confirmation of the new one. All three must be present, and the two new entries must match. Use the new PIN for that organization from then on. This also needs the `organization.security.manage` permission, so a member without it can confirm actions with the existing PIN but cannot change it. ## If the PIN is rejected [#if-the-pin-is-rejected] Work through these in order. 1. Confirm the active organization in the sidebar selector. 2. Confirm you are entering the transaction PIN rather than a card PIN or your login password. 3. Check whether a teammate with `organization.security.manage` changed it recently. 4. Stop guessing after a couple of attempts. If nobody knows the current PIN, contact support. You verify your identity against the organization, support resets the PIN to a placeholder, and you then change it to a PIN of your own from the dashboard. There is no self-service reset. Creating another organization does not recover access to the original organization's funds, and the new organization starts with no PIN, no balances, and no verification. ## Keep the PIN safe [#keep-the-pin-safe] * Do not use `0000`, `1111`, a birth year, or another guessable sequence. Nothing in the transaction PIN flow rejects a weak code for you, unlike the card PIN flow, so the choice is entirely yours. * Do not put the PIN in chat, email, screenshots, or team documentation. * Give teammates their own membership and role rather than passing the PIN around as a shared credential. * No Seev employee will ask you to send the PIN. ## Related guides [#related-guides] * [Accounts and Wallets](/docs/accounts) * [Cards](/docs/accounts/cards) * [Yield](/docs/accounts/yield) * [Team Access](/docs/settings/team) # Virtual Accounts (/docs/accounts/virtual-accounts) A USD receiving account gives the organization US bank details it can use to accept USD transfers. The dashboard calls it the **USD account**, and it belongs to the active organization rather than to you. Setting one up is optional. An organization can hold and spend USDC without ever having a USD account; the dollar balance card shows a USDC-only badge until an account exists. See [Accounts and Wallets](/docs/accounts) for what that card tells you. ## Set up the USD account [#set-up-the-usd-account] Open **Balances**. A USD account card sits below the balances with the heading "Add a USD receiving account" until one exists. Select **Set up USD account**, accept the bank terms when prompted, and work through the setup flow. Activation requires approved organization verification. An organization that has not been verified cannot activate the account, so complete [verification](/docs/organizations/verification) first rather than repeatedly restarting setup. Wait for the account details to appear before you share anything. Details that have not loaded are not details you can give a payer. ## Track the review state [#track-the-review-state] The USD account card changes as the application moves, and the wording tells you whether you are waiting or whether you need to act. | Card heading | Meaning | What to do | | -------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | Add a USD receiving account | No application exists yet. | Start setup. | | Your USD account is under review | The details were submitted and are being reviewed. The GHS balance stays available meanwhile. | Wait. There is no action button in this state, by design. | | Complete your USD account setup | Business ownership details are still outstanding before the account can be activated. | Select **Continue setup** and supply them. | | Update your USD account details | The application was rejected. | Select **Continue setup**, review the details, and correct them. | The dashboard states that review typically takes one to two business days. An organization that has no GHS wallet yet sees these states as a full-page notice rather than as a card, and the notice for outstanding or rejected details points you to support rather than back into the form. If that is what you see, contact support instead of hunting for a setup button that is not there. ## Share the receiving details [#share-the-receiving-details] Once the account is ready, the account details panel on Balances shows a **US account details** tab. Give the payer these fields exactly as displayed: | Field | Description | | ------------------- | -------------------------------------------------------------------------------- | | Account holder name | Do not shorten, abbreviate, or substitute a trading name. | | Account number | The receiving account number. | | Account type | Shown on the panel; pass it on as displayed. | | Bank routing number | The ACH routing number, which senders using wire may need to confirm separately. | | Bank name | The receiving bank. | | Bank address | The bank's address, which some senders require. | The dashboard states the following terms for this account, and they determine whether a given transfer will work at all: * The account sends and receives USD by bank transfer over ACH, Wire, and SWIFT. * Receiving by ACH costs 0.8%, with a $2 minimum and a $10 maximum. * USD payments can only be received from banks inside the United States. * Incoming payments take between one and three days depending on the sending bank's scheme. A payer sending from outside the United States will not reach this account. Confirm where the sending bank is before you give out the details, rather than after a transfer goes missing. Incoming funds land in the owning organization's balance and appear in the Activity feed after reconciliation. ## Avoid a delayed deposit [#avoid-a-delayed-deposit] * Give the beneficiary name exactly as shown, with no abbreviation. * Do not accept an unsupported currency into this account. * Do not reuse another organization's details; each organization's account is its own. * Include any reference the panel asks for. * Keep the sender's receipt and reference until the funds appear. If a transfer was sent but has not arrived, compare the sender's receipt against the displayed details field by field before sending anything else. Check the Activity feed on Balances first, since a deposit that has reconciled is already there. ## Related guides [#related-guides] * [Accounts and Wallets](/docs/accounts) * [Organization Verification](/docs/organizations/verification) * [Payout Schedules](/docs/settings/payouts) # Yield (/docs/accounts/yield) Yield lets the organization earn on an idle dollar balance, with no lock-up on withdrawals. The dashboard shows the yield account's current rate, its balance over time, and its transaction history. ## Find or activate yield [#find-or-activate-yield] Yield does not appear under **Manage** in the sidebar until the organization has a yield account. Before that, the entry point is a yield banner on **Balances**, which appears when the organization has a USD account and the viewer holds the `yield.read` permission. Select **Get started** on that banner and confirm activation. On success Seev takes you straight to the deposit screen. If activation fails you get "Could not activate your yield, please contact support"; nothing is created, so contact support rather than retrying repeatedly. If you can see neither the sidebar item nor the banner, work through these in order: 1. Confirm the correct organization is active. Yield accounts do not carry across organizations. 2. Confirm the organization has a USD account. See [Virtual Accounts](/docs/accounts/virtual-accounts). 3. Confirm your role grants `yield.read`. Owners and admins hold it; a member needs a custom role that includes it. See [Team Access](/docs/settings/team). The deposit and withdrawal actions need `yield.deposit` and `yield.withdraw` respectively, so a teammate can hold read access to the page without being able to move money on it. ## Deposit into yield [#deposit-into-yield] Open Yield and select **Deposit**. Choose the source balance, enter the amount, review it, then confirm with the organization's [transaction PIN](/docs/accounts/pin). The minimum deposit depends on the currency you deposit in. | Currency | Minimum deposit | | -------- | --------------- | | GHS | 150.00 | | USD | 2.00 | Below the minimum, the form shows "Amount must be at least 150.00" or "Amount must be at least 2.00" and does not submit. A non-numeric entry shows "Amount must be a number", and an empty one shows "Amount is required". These amounts are major units, matching the balances shown elsewhere in the dashboard. ## Withdraw from yield [#withdraw-from-yield] Select **Withdraw**, enter an amount, review, then confirm with the transaction PIN. The same currency minimums apply. Asking for more than the yield balance holds shows "Insufficient balance" and submits nothing. Check the yield balance rather than the source wallet balance, since these are different figures. Pending deposits and withdrawals stay visible in the yield transaction list. Do not create a second movement while an earlier one is pending; a duplicate deposit is real money moved twice. ## Read the rate honestly [#read-the-rate-honestly] The rate is displayed as an APY, which is an annualized figure rather than what you will earn immediately and rather than a promise about the future. The displayed APY can change. What the organization actually earns depends on the balance held, how long it is held, the rate over that period, and the product terms in force. Do not read the current rate as a guaranteed return, and do not quote it to anyone as one. Interest is paid out in USDC. The transaction list on the Yield page carries deposits, interest payments, and withdrawals; an account that has not moved yet reads "No yield activity yet" rather than showing zeroes. ## If a yield action is blocked [#if-a-yield-action-is-blocked] Read the message on the form first, since it names the cause. The usual ones are an amount below the minimum, an amount above the available yield balance, a missing or wrong transaction PIN, or a source balance that has a pending movement not yet spendable. Switching organizations switches the yield account along with everything else, so an action that worked a moment ago and now fails is worth checking against the organization selector before anything else. ## Related guides [#related-guides] * [Accounts and Wallets](/docs/accounts) * [Transaction PIN](/docs/accounts/pin) * [Virtual Accounts](/docs/accounts/virtual-accounts) * [Team Access](/docs/settings/team) # SEP-10: Authentication (/docs/anchor/authentication) SEP-10 is a mutual authentication protocol between a Stellar wallet and an anchor. It proves that you control a specific Stellar account without exposing your secret key. On success, the anchor issues a JWT token used to authorize all subsequent SEP-6, SEP-12, and SEP-38 requests. ## Flow overview [#flow-overview] ``` Client Anchor │ │ │ GET /auth?account={publicKey} │ │──────────────────────────────────────►│ │ │ │ Challenge transaction (XDR) │ │◄──────────────────────────────────────│ │ │ │ Sign challenge with secret key │ │ │ │ POST /auth (signed XDR) │ │──────────────────────────────────────►│ │ │ │ JWT token │ │◄──────────────────────────────────────│ ``` ## Endpoints [#endpoints] | Method | Path | Description | | ------ | ------- | --------------------------------------------- | | GET | `/auth` | Request a challenge transaction | | POST | `/auth` | Submit the signed challenge and receive a JWT | ## Step 1: Request a challenge [#step-1-request-a-challenge] ```bash curl -X GET "https://anchor.seevcash.com/auth?account=GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY" ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; // Setup const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Request and sign the challenge in one step const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); ``` **Response (200):** ```json { "transaction": "AAAAAgAAAADHPbSgR...base64_xdr_challenge...==", "network_passphrase": "Test SDF Network ; September 2015" } ``` The `transaction` field contains a base64-encoded Stellar transaction XDR. This transaction is not submitted to the network — it's a cryptographic challenge. ## Step 2: Sign and submit the challenge [#step-2-sign-and-submit-the-challenge] Sign the challenge transaction with the secret key corresponding to your public key, then POST the signed XDR back to the anchor. ```bash curl -X POST "https://anchor.seevcash.com/auth" \ -H "Content-Type: application/json" \ -d '{ "transaction": "AAAAAgAAAADHPbSgR...signed_base64_xdr...==" }' ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; // The wallet SDK handles challenge request, signing, and submission internally. // sep10.authenticate() performs all three steps: // 1. GET /auth?account=... (request challenge) // 2. Sign the challenge XDR with accountKp // 3. POST /auth (submit signed challenge) // Returns the JWT token directly. const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); console.log("SEP-10 JWT:", authToken); ``` **Response (200):** ```json { "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FuY2hvci1wcm9kLnNlZXZjYXNoLmNvbSIsInN1YiI6IkdDRVhBTVBMRTRLRVlQQUlSN0hFUkUyUkVQTEFDRTVXSVRINVlPVVI1QUNUVUFMNVNURU..." } ``` The JWT token typically expires after 24 hours. Cache it and refresh before expiry. All subsequent API calls must include it as a Bearer token. ## TypeScript example [#typescript-example] ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); async function authenticate(): Promise { const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); return authToken; } // Usage const jwt = await authenticate(); console.log("SEP-10 JWT:", jwt); ``` ## Using the token [#using-the-token] Include the JWT in all subsequent requests to SEP-6, SEP-12, and SEP-38 endpoints: ```bash curl -X GET "https://anchor.seevcash.com/sep6/info" \ -H "Authorization: Bearer eyJhbGciOiJFZERTQSI..." ``` ## Error responses [#error-responses] | Status | Error | Meaning | | ------ | --------------------- | ----------------------------------------------------- | | 400 | `invalid_request` | Missing or malformed `account` parameter | | 400 | `invalid_transaction` | Challenge XDR is malformed or expired | | 401 | `unauthorized` | Signature does not match the account in the challenge | | 404 | `not_found` | Account not recognized or challenge expired | ## Security notes [#security-notes] * Never expose your Stellar secret key in client-side code or logs * The challenge transaction is single-use and time-limited (typically 5 minutes) * The anchor verifies the signature matches the public key from the challenge * JWTs are scoped to a single Stellar account * Store tokens securely; treat them like session credentials # SEP-6: Deposit (GHS → USDC) (/docs/anchor/deposit) A deposit converts GHS (Ghana Cedis) to USDC on the Stellar network. The customer pays via Mobile Money and receives USDC in their Stellar wallet. This page covers the full programmatic SEP-6 deposit flow. ## Flow overview [#flow-overview] ``` Client Anchor MoMo Gateway Stellar │ │ │ │ │ GET /sep6/deposit │ │ │ │────────────────────────►│ │ │ │ { id, extra_info } │ │ │ │◄────────────────────────│ │ │ │ │ │ │ │ │ Collect GHS (auto) │ │ │ │──────────────────────────►│ │ │ │ Payment confirmed │ │ │ │◄──────────────────────────│ │ │ │ │ │ │ │ Send USDC │ │ │ │──────────────────────────────────────────►│ │ │ │ │ │ on_change_callback │ │ │ │◄────────────────────────│ │ │ ``` ## Prerequisites [#prerequisites] 1. Valid SEP-10 JWT token ([Authentication](/docs/anchor/authentication)) 2. KYC status is `ACCEPTED` ([KYC](/docs/anchor/kyc)) 3. Stellar account has a USDC trustline established ## Step 1: Check anchor capabilities [#step-1-check-anchor-capabilities] ```bash curl -X GET "https://anchor.seevcash.com/sep6/info" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Get SEP-6 info const sep6 = anchor.sep6(); const info = await sep6.info(); console.log("Deposit assets:", info.deposit); console.log("USDC min:", info.deposit.USDC.min_amount); console.log("USDC max:", info.deposit.USDC.max_amount); ``` **Response:** ```json { "deposit": { "USDC": { "enabled": true, "authentication_required": true, "min_amount": 1, "max_amount": 10000, "fee_fixed": 0, "fee_percent": 0, "funding_methods": ["bank_account", "mobile", "cash"] } }, "withdraw": { "USDC": { "enabled": true, "authentication_required": true, "min_amount": 1, "max_amount": 10000, "fee_fixed": 0, "fee_percent": 0, "types": { "bank_account": { "fields": { "dest": { "description": "Bank account number", "optional": false }, "dest_extra": { "description": "Bank routing number", "optional": true } } }, "mobile": { "fields": { "dest": { "description": "Mobile money phone number", "optional": false }, "dest_extra": { "description": "Mobile money provider (e.g. MTN, Vodafone, AirtelTigo)", "optional": false } } } }, "funding_methods": ["bank_account", "mobile", "cash"] } }, "fee": { "enabled": false }, "transactions": { "enabled": true, "authentication_required": true }, "transaction": { "enabled": true, "authentication_required": true }, "features": { "account_creation": true, "claimable_balances": true } } ``` The anchor also advertises `deposit-exchange` and `withdraw-exchange` entries (for SEP-38-quoted conversions). The response above is abbreviated to the fields most integrations use. `fee.enabled` is `false` — the fee is built into the exchange rate rather than charged as a separate SEP-6 fee. ## Step 2: Initiate a deposit [#step-2-initiate-a-deposit] The `amount` parameter represents the USDC amount you want to receive. The anchor calculates the equivalent GHS the customer needs to pay based on the current exchange rate. ```bash curl -X GET "https://anchor.seevcash.com/sep6/deposit?\ asset_code=USDC&\ account=GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY&\ amount=6.5&\ funding_method=mobile&\ on_change_callback=https://yourapp.com/webhooks/anchor" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Initiate deposit const sep6 = anchor.sep6(); const deposit = await sep6.deposit({ authToken, params: { asset_code: 'USDC', account: accountKp.publicKey, amount: '6.5', funding_method: 'mobile', }, }); console.log("Deposit ID:", deposit.id); ``` ### Parameters [#parameters] | Parameter | Required | Description | | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | | `asset_code` | Yes | Asset to deposit — `USDC` | | `account` | Yes | Stellar public key to receive USDC | | `funding_method` | Yes | Payment method — `mobile` for Mobile Money (`type` is accepted as an alias) | | `amount` | No | Amount of USDC to receive (anchor converts to GHS) | | `quote_id` | No | A SEP-38 `quote_id` to lock the rate | | `collection_mode` | No | `auto` (default) — anchor sends an STK push to collect; `manual` — the caller orchestrates collection. See below | | `on_change_callback` | No | URL to receive transaction status updates | | `memo` | No | Memo to attach to the Stellar payment | | `memo_type` | No | Memo type: `text`, `id`, or `hash` | In `auto` collection mode with `funding_method=mobile`, the anchor needs the customer's mobile money number and provider. If they are not already on the customer's SEP-12 record (and not supplied on the request), the deposit call returns **`403` with `type: "customer_info_needed"`** and a `transaction_id` in status `pending_customer_info_update` — not a normal deposit body. Submit `mobile_money_number` + `mobile_money_provider` via `PUT /sep12/customer` (passing that `transaction_id`), then the deposit proceeds. See [KYC](/docs/anchor/kyc). ## Auto vs Manual collection modes [#auto-vs-manual-collection-modes] ### Auto collection [#auto-collection] When the anchor can automatically collect via STK push: ```json { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "how": "mobile_money", "extra_info": { "message": "A Mobile Money prompt will be sent to +233241234567. Approve the payment of GHS 100.00 to complete your deposit of 6.50 USDC." }, "min_amount": "1", "max_amount": "10000", "eta": 300 } ``` In auto mode, the anchor sends an STK push (payment prompt) directly to the customer's registered Mobile Money number. The customer approves the prompt on their phone. ### Manual collection [#manual-collection] When auto-collection is not available: ```json { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "how": "mobile_money", "instructions": { "network": "MTN", "short_code": "*170#", "recipient": "0241000000", "reference": "SEEV-DEP-8F3A4B", "amount": "GHS 100.00" }, "min_amount": "1", "max_amount": "10000", "eta": 600 } ``` In manual mode, the customer must initiate the payment themselves using the provided instructions. ## Amount handling [#amount-handling] | You send | Meaning | | ------------ | -------------------------------------------------------------------------------------------------- | | `amount=6.5` | You want to receive 6.5 USDC. Anchor calculates GHS equivalent (\~100 GHS at rate 15.38 GHS/USDC). | | No `amount` | Anchor returns min/max. Customer decides how much GHS to send. | The exchange rate includes the anchor's fee. Use [SEP-38 Quotes](/docs/anchor/quotes) to lock in a rate before initiating the deposit. ## Step 3: Poll transaction status [#step-3-poll-transaction-status] ```bash curl -X GET "https://anchor.seevcash.com/sep6/transaction?id=txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Use the watcher for real-time transaction tracking const sep6 = anchor.sep6(); const watcher = sep6.watcher(); const { stop, refresh } = watcher.watchOneTransaction({ authToken, assetCode: 'USDC', id: 'txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c', onMessage: (txn) => console.log(`Status: ${txn.status}`), onSuccess: (txn) => { console.log(`Received ${txn.amount_out} USDC`); console.log(`Stellar tx: ${txn.stellar_transaction_id}`); }, onError: (err) => console.error('Error:', err), }); // Or get a single transaction const { transaction } = await sep6.getTransactionBy({ authToken, id: 'txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c', }); console.log(`Status: ${transaction.status}`); ``` **Response (pending):** ```json { "transaction": { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "kind": "deposit", "status": "pending_user_transfer_start", "status_eta": 300, "amount_in": "100.00", "amount_in_asset": "iso4217:GHS", "amount_out": "6.50", "amount_out_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "amount_fee": "0.10", "amount_fee_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "started_at": "2026-08-05T01:15:00Z", "more_info_url": "https://anchor.seevcash.com/sep6/transaction/more_info?id=txn_8f3a4b2c" } } ``` **Response (completed):** ```json { "transaction": { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "kind": "deposit", "status": "completed", "amount_in": "100.00", "amount_in_asset": "iso4217:GHS", "amount_out": "6.50", "amount_out_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "amount_fee": "0.10", "amount_fee_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "started_at": "2026-08-05T01:15:00Z", "completed_at": "2026-08-05T01:19:30Z", "stellar_transaction_id": "abc123def456...", "more_info_url": "https://anchor.seevcash.com/sep6/transaction/more_info?id=txn_8f3a4b2c" } } ``` ## on\_change\_callback [#on_change_callback] If you provided an `on_change_callback` URL, the anchor POSTs status updates to it: ```json { "transaction": { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "status": "completed", "stellar_transaction_id": "abc123def456..." } } ``` Do not rely solely on callbacks — always verify transaction status by polling. Callbacks are best-effort and may be delayed or missed. ## Deposit status flow [#deposit-status-flow] ``` pending_user_transfer_start → User needs to approve/send MoMo payment pending_anchor → Anchor received GHS, processing USDC send completed → USDC sent to customer's Stellar address error → Something went wrong (check message) expired → Deposit window expired without payment ``` See [Transactions](/docs/anchor/transactions) for the full status lifecycle. ## Full TypeScript example [#full-typescript-example] ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); async function deposit() { // Step 1: Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Step 2: Initiate deposit of 6.5 USDC (≈ GHS 100) const sep6 = anchor.sep6(); const deposit = await sep6.deposit({ authToken, params: { asset_code: 'USDC', account: accountKp.publicKey, amount: '6.5', funding_method: 'mobile', }, }); console.log("Deposit initiated:", deposit.id); // Step 3: Watch for completion using the watcher const watcher = sep6.watcher(); const { stop } = watcher.watchOneTransaction({ authToken, assetCode: 'USDC', id: deposit.id, onMessage: (txn) => { console.log(`Status: ${txn.status}`); if (txn.status === 'pending_user_transfer_start') { console.log('Approve the Mobile Money prompt on your phone.'); } }, onSuccess: (txn) => { console.log(`Received ${txn.amount_out} USDC`); console.log(`Stellar tx: ${txn.stellar_transaction_id}`); stop(); }, onError: (err) => { console.error('Deposit error:', err); stop(); }, }); } await deposit(); ``` ## Establishing a USDC trustline [#establishing-a-usdc-trustline] Before the anchor can send USDC to your account, you must trust the USDC asset: ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // The wallet SDK's stellar module handles trustline operations const stellar = wallet.stellar(); const txBuilder = await stellar.transaction({ sourceAddress: accountKp }); // Add USDC trustline const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; txBuilder.addAssetSupport(new IssuedAssetId('USDC', USDC_ISSUER)); const tx = txBuilder.build(); tx.sign(accountKp); await stellar.submitTransaction(tx); console.log("USDC trustline established"); ``` ## Error responses [#error-responses] | Status | Error | Meaning | | ------ | ---------------------- | ------------------------------- | | 400 | `invalid_amount` | Amount outside min/max range | | 400 | `invalid_asset_code` | Asset not supported | | 403 | `kyc_required` | KYC not yet approved | | 403 | `customer_info_needed` | Additional KYC fields required | | 429 | `rate_limit` | Too many requests | | 500 | `internal_error` | Anchor-side error — retry later | # Stellar Anchor (/docs/anchor) SeevCash operates a [Stellar anchor](https://stellar.org/learn/anchor-basics) that bridges USDC on the Stellar network with Ghana Cedis (GHS) via MTN, Vodafone, and AirtelTigo Mobile Money. The anchor implements the full SEP (Stellar Ecosystem Proposal) protocol suite for programmatic deposits, withdrawals, KYC, and quotes. ## Supported asset [#supported-asset] | Asset | Code | Issuer | Network | | -------- | ---- | ---------------------------------------------------------- | ------------------------ | | USD Coin | USDC | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | Stellar Mainnet (Public) | ## Supported fiat [#supported-fiat] | Currency | Code | Channels | | ----------- | ---- | ------------------------------------------------- | | Ghana Cedis | GHS | MTN Mobile Money, Vodafone Cash, AirtelTigo Money | GHS is off-chain fiat — it is **not** an issued Stellar asset. It is delivered via Mobile Money and modeled as `iso4217:GHS` in SEP-38 quotes and SEP-31 payouts. ## Architecture [#architecture] ``` ┌─────────────┐ SEP-10 Auth ┌──────────────────────┐ │ Wallet / │◄────────────────────────►│ SeevCash Anchor │ │ Client │ │ anchor.seevcash.com │ │ │ SEP-12 KYC │ │ │ │◄────────────────────────►│ ┌──────────────┐ │ │ │ │ │ KYC Service │ │ │ │ SEP-6 Deposit/Withdraw │ └──────────────┘ │ │ │◄────────────────────────►│ │ │ │ │ ┌──────────────┐ │ │ │ SEP-38 Quotes │ │ Quote Engine│ │ │ │◄────────────────────────►│ └──────────────┘ │ └─────────────┘ │ │ │ ┌──────────────┐ │ ┌─────────────┐ │ │ Payment │ │ │ Stellar │ Horizon / Payment Watch │ │ Monitor │ │ │ Network │◄────────────────────────►│ └──────────────┘ │ └─────────────┘ │ │ │ ┌──────────────┐ │ ┌─────────────┐ │ │ MoMo │ │ │ Mobile │ Collect / Disburse │ │ Gateway │ │ │ Money APIs │◄────────────────────────►│ └──────────────┘ │ └─────────────┘ └──────────────────────┘ ``` ## Implemented SEPs [#implemented-seps] | SEP | Purpose | Description | | --------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------- | | [SEP-1](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0001.md) | Stellar Info File | `stellar.toml` at `/.well-known/stellar.toml` advertising anchor capabilities | | [SEP-6](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0006.md) | Deposit & Withdrawal API | Programmatic on/off-ramp without interactive UI | | [SEP-9](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0009.md) | Standard KYC Fields | Shared field definitions used by SEP-12 | | [SEP-10](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md) | Web Authentication | Mutual authentication between wallet and anchor | | [SEP-12](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0012.md) | KYC API | Submit and manage customer verification data | | [SEP-24](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md) | Interactive Deposit & Withdrawal | Anchor-hosted interactive UI flow | | [SEP-31](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0031.md) | Cross-Border Payments API | Business-to-business receive: USDC in, GHS Mobile Money payout | | [SEP-38](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0038.md) | Anchor RFQ API | Request firm quotes for conversions | ## Integration flow [#integration-flow] A typical deposit (GHS → USDC) follows this sequence: 1. **Authenticate** — SEP-10 challenge/response to get a JWT token 2. **KYC** — SEP-12 to register and verify the customer 3. **Quote** (optional) — SEP-38 to lock in an exchange rate 4. **Deposit** — SEP-6 to initiate the on-ramp transaction 5. **Collect** — Anchor collects GHS via Mobile Money (auto or manual) 6. **Settle** — Anchor sends USDC to the customer's Stellar address All SEP-6 endpoints require a valid SEP-10 JWT in the `Authorization` header. Complete authentication first. ## Quick links [#quick-links] ## Stellar Info File (SEP-1) [#stellar-info-file-sep-1] The anchor publishes a `stellar.toml` at: ``` https://anchor.seevcash.com/.well-known/stellar.toml ``` This file advertises the transfer server URL, authentication server, KYC server, and supported assets. Wallets use this file to discover anchor capabilities automatically. # SEP-12: KYC (/docs/anchor/kyc) SEP-12 defines a standard API for submitting Know Your Customer (KYC) data. Before a customer can deposit or withdraw, the anchor must verify their identity. KYC data is submitted once and linked to the authenticated Stellar account. ## Endpoints [#endpoints] | Method | Path | Description | | ------ | ----------------- | ------------------------------------------ | | GET | `/sep12/customer` | Get current KYC status and required fields | | PUT | `/sep12/customer` | Submit or update KYC data | | DELETE | `/sep12/customer` | Delete customer KYC record | All endpoints require a valid SEP-10 JWT token. ## KYC status lifecycle [#kyc-status-lifecycle] ``` ┌──────────┐ PUT /customer ┌────────────────┐ │ NEEDS │ ──────────────────────►│ PROCESSING │ │ DATA │ │ (under review)│ └──────────┘ └───────┬────────┘ │ ┌──────────────┼──────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │ ACCEPTED │ │ NEEDS_INFO │ │ REJECTED │ └──────────┘ │ (more fields)│ └──────────┘ └──────────────┘ ``` | Status | Meaning | | ------------ | ------------------------------------------------------------------ | | `NEEDS_DATA` | No KYC submitted yet. Submit required fields. | | `PROCESSING` | KYC submitted, under review. | | `ACCEPTED` | KYC approved. Customer can transact. | | `NEEDS_INFO` | Additional information required. Check `fields` for what's needed. | | `REJECTED` | KYC rejected. Customer cannot transact. | ## Required fields for Ghana corridor [#required-fields-for-ghana-corridor] ### Individual customers (deposit/withdrawal) [#individual-customers-depositwithdrawal] | Field (SEP-9) | Type | Description | Required | | ----------------------- | ------ | --------------------------------------------------------- | ------------------------ | | `first_name` | string | Customer's first name | Yes | | `last_name` | string | Customer's last name | Yes | | `email_address` | string | Email address | Yes | | `mobile_number` | string | Phone number (e.g., `+233241234567`) | Yes | | `mobile_money_number` | string | Mobile money wallet number (E.164 format) | Yes (for mobile funding) | | `mobile_money_provider` | string | MoMo provider: `MTN`, `VODAFONE`, `TELECEL`, `AIRTELTIGO` | Yes (for mobile funding) | | `id_type` | string | Type of ID: `ghana_card`, `passport`, `voter_id` | Yes | | `id_number` | string | ID document number | Yes | | `date_of_birth` | string | Date of birth (YYYY-MM-DD) | Yes | | `address` | string | Residential address | No | The `mobile_number` must include the country code (+233). This number is used for Mobile Money collection and disbursement. ## Mobile money fields for SEP-6 [#mobile-money-fields-for-sep-6] When using SEP-6 deposits or withdrawals with `funding_method=mobile`, the anchor requires additional mobile money-specific fields. These are requested when you call `GET /sep12/customer` with a `transaction_id` from a SEP-6 deposit/withdrawal. ### Required mobile money fields [#required-mobile-money-fields] | Field (SEP-9) | Type | Description | Example | | ----------------------- | ------ | ----------------------------------------- | --------------- | | `mobile_money_number` | string | Mobile money wallet number (E.164 format) | `+233244123456` | | `mobile_money_provider` | string | Mobile money network provider | `MTN` | ### Supported providers [#supported-providers] | Provider | Value | Phone prefixes | | ---------------- | ------------ | ---------------------------- | | MTN Mobile Money | `MTN` | 024, 025, 053, 054, 055, 059 | | Vodafone Cash | `VODAFONE` | 020, 050 | | Telecel Cash | `TELECEL` | 026, 027, 056, 057 | | AirtelTigo Money | `AIRTELTIGO` | 026, 027, 056, 057 | `mobile_money_number` and `mobile_money_provider` are separate from the basic `mobile_number` field. The anchor uses `mobile_money_number` as the source for collections (deposits) and destination for payouts (withdrawals). If only `mobile_number` is provided, the anchor falls back to it. ### How mobile money fields connect to SEP-6 [#how-mobile-money-fields-connect-to-sep-6] When you initiate a SEP-6 deposit with `funding_method=mobile`: 1. **If mobile money details exist in KYC** — the anchor triggers collection immediately (auto mode) 2. **If mobile money details are missing** — transaction goes to `pending_customer_info_update` To resolve `pending_customer_info_update`: ```bash # 1. Check what fields are needed (pass transaction_id) curl -X GET "https://anchor.seevcash.com/sep12/customer?account=GUSER...&transaction_id=txn-abc-123" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate first const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // 1. Check what fields are needed const sep12 = await anchor.sep12(authToken); const customer = await sep12.getCustomer({ transactionId: 'txn-abc-123' }); console.log("Missing fields:", Object.keys(customer.fields)); ``` Response will include the missing mobile money fields: ```json { "id": "customer-uuid", "status": "NEEDS_INFO", "fields": { "mobile_money_number": { "type": "string", "description": "Mobile money phone number (E.164 format, e.g. +233244123456)" }, "mobile_money_provider": { "type": "string", "description": "Mobile money service provider", "choices": ["MTN", "VODAFONE", "TELECEL", "AIRTELTIGO"] } } } ``` ```bash # 2. Submit the mobile money details curl -X PUT "https://anchor.seevcash.com/sep12/customer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "account": "GUSER...", "transaction_id": "txn-abc-123", "mobile_money_number": "+233244123456", "mobile_money_provider": "MTN" }' ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // 2. Submit mobile money details const sep12 = await anchor.sep12(authToken); const result = await sep12.add({ sep9Info: { mobile_money_number: '+233244123456', mobile_money_provider: 'MTN', }, }); console.log("Status:", result.status); // "ACCEPTED" ``` Response: ```json { "id": "customer-uuid", "status": "ACCEPTED" } ``` After this, the anchor automatically: * Advances the transaction to `pending_user_transfer_start` * Triggers the mobile money collection (deposit) or payout (withdrawal) ### Complete KYC submission with mobile money [#complete-kyc-submission-with-mobile-money] For the best experience, submit all fields including mobile money details upfront: ```bash curl -X PUT "https://anchor.seevcash.com/sep12/customer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "account": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "first_name": "Patrick", "last_name": "Oduro", "email_address": "patrick@example.com", "mobile_number": "+233542853417", "mobile_money_number": "+233542853417", "mobile_money_provider": "MTN", "id_type": "ghana_card", "id_number": "GHA-123456789-0", "date_of_birth": "1992-03-20" }' ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); async function submitFullKyc() { const sep12 = await anchor.sep12(authToken); const result = await sep12.add({ sep9Info: { first_name: 'Patrick', last_name: 'Oduro', email_address: 'patrick@example.com', mobile_number: '+233542853417', mobile_money_number: '+233542853417', mobile_money_provider: 'MTN', id_type: 'ghana_card', id_number: 'GHA-123456789-0', date_of_birth: '1992-03-20', }, }); console.log("KYC Status:", result.status); // "ACCEPTED" return result; } ``` Submitting `mobile_money_number` and `mobile_money_provider` during initial KYC means the SEP-6 deposit flow can trigger collection immediately without going through `pending_customer_info_update`. ## Step 1: Check KYC status [#step-1-check-kyc-status] ```bash curl -X GET "https://anchor.seevcash.com/sep12/customer?account=GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const sep12 = await anchor.sep12(authToken); const status = await sep12.getCustomer({}); console.log("KYC Status:", status.status); console.log("Required fields:", Object.keys(status.fields || {})); ``` **Response (new customer):** ```json { "id": "d1c3e245-8a67-4e3c-b12f-1a2b3c4d5e6f", "status": "NEEDS_DATA", "fields": { "first_name": { "description": "Customer's first name", "type": "string", "optional": false }, "last_name": { "description": "Customer's last name", "type": "string", "optional": false }, "email_address": { "description": "Email address", "type": "string", "optional": false }, "mobile_number": { "description": "Mobile money number with country code", "type": "string", "optional": false }, "id_type": { "description": "Type of ID document", "type": "string", "choices": ["ghana_card", "passport", "voter_id"], "optional": false }, "id_number": { "description": "ID document number", "type": "string", "optional": false }, "date_of_birth": { "description": "Date of birth (YYYY-MM-DD)", "type": "string", "optional": false } } } ``` ## Step 2: Submit KYC data [#step-2-submit-kyc-data] ```bash curl -X PUT "https://anchor.seevcash.com/sep12/customer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "account": "GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY", "first_name": "Kwame", "last_name": "Asante", "email_address": "kwame@example.com", "mobile_number": "+233241234567", "id_type": "ghana_card", "id_number": "GHA-123456789-0", "date_of_birth": "1990-05-15" }' ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const sep12 = await anchor.sep12(authToken); const result = await sep12.add({ sep9Info: { first_name: 'Kwame', last_name: 'Asante', email_address: 'kwame@example.com', mobile_number: '+233241234567', mobile_money_number: '+233241234567', mobile_money_provider: 'MTN', id_type: 'ghana_card', id_number: 'GHA-123456789-0', date_of_birth: '1990-05-15', }, }); console.log("KYC status:", result.status); // "ACCEPTED" ``` **Response (200):** ```json { "id": "d1c3e245-8a67-4e3c-b12f-1a2b3c4d5e6f", "status": "ACCEPTED" } ``` For sandbox/testnet, KYC is typically auto-approved immediately. In production, there may be a `PROCESSING` state while the anchor reviews submissions. ## TypeScript example [#typescript-example] ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); async function submitKyc() { // Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Submit KYC const sep12 = await anchor.sep12(authToken); const result = await sep12.add({ sep9Info: { first_name: 'Kwame', last_name: 'Asante', email_address: 'kwame@example.com', mobile_number: '+233241234567', mobile_money_number: '+233241234567', mobile_money_provider: 'MTN', id_type: 'ghana_card', id_number: 'GHA-123456789-0', date_of_birth: '1990-05-15', }, }); console.log("KYC status:", result.status); // "ACCEPTED" return result; } async function getKycStatus() { const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const sep12 = await anchor.sep12(authToken); return sep12.getCustomer({}); } // Usage const kycResult = await submitKyc(); console.log("KYC status:", kycResult.status); // "ACCEPTED" ``` ## Handling NEEDS\_INFO [#handling-needs_info] If the anchor needs additional information, the response will indicate which fields are missing: ```json { "id": "d1c3e245-8a67-4e3c-b12f-1a2b3c4d5e6f", "status": "NEEDS_INFO", "fields": { "address": { "description": "Residential address is required for this transaction amount", "type": "string", "optional": false } }, "message": "Additional information required for transactions above GHS 5,000" } ``` Re-submit with the additional fields using the same PUT endpoint. ## Delete customer data [#delete-customer-data] To remove all KYC data associated with an account: ```bash curl -X DELETE "https://anchor.seevcash.com/sep12/customer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "account": "GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY" }' ``` ## Mobile number format [#mobile-number-format] | Network | Example | Format | | ---------- | --------------- | --------------- | | MTN | `+233241234567` | +233 + 9 digits | | Vodafone | `+233201234567` | +233 + 9 digits | | AirtelTigo | `+233261234567` | +233 + 9 digits | The mobile number submitted in KYC is used as the default payout destination for withdrawals and the collection source for deposits. Ensure it matches an active Mobile Money wallet. # SEP-38: Quotes (/docs/anchor/quotes) SEP-38 provides a Request for Quote (RFQ) API that lets you discover available asset pairs, check indicative prices, and lock in firm quotes before initiating a deposit or withdrawal. Using quotes ensures the customer knows exactly how much they'll pay or receive. ## Endpoints [#endpoints] | Method | Path | Description | | ------ | ------------------ | ----------------------------------------------- | | GET | `/sep38/prices` | List available asset pairs and indicative rates | | GET | `/sep38/price` | Get an indicative price for a specific pair | | POST | `/sep38/quote` | Create a firm quote with a locked rate | | GET | `/sep38/quote/:id` | Retrieve an existing quote | All endpoints require a valid SEP-10 JWT token. ## Asset identifiers [#asset-identifiers] SEP-38 uses standardized asset identifiers: | Asset | Identifier | | --------------- | ----------------------------------------------------------------------- | | USDC on Stellar | `stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | | Ghana Cedis | `iso4217:GHS` | ## GET /prices — List available pairs [#get-prices--list-available-pairs] ```bash curl -X GET "https://anchor.seevcash.com/sep38/prices?\ sell_asset=iso4217:GHS&\ sell_amount=100" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; // Note: The wallet SDK does not have SEP-38 built in. // Use fetch for quote operations until SDK support is added. const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Fetch prices via SEP-38 (raw fetch — not yet in wallet SDK) const toml = await anchor.getInfo(); const quoteServer = toml.anchorQuoteServer; const params = new URLSearchParams({ sell_asset: 'iso4217:GHS', sell_amount: '100', }); const res = await fetch(`${quoteServer}/sep38/prices?${params}`, { headers: { Authorization: `Bearer ${authToken}` }, }); const data = await res.json(); console.log("Available buy assets:", data.buy_assets); ``` **Response:** ```json { "price": "15.38", "sell_amount": "100.00", "buy_amount": "6.50", "fee": { "total": "0.10", "asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" } } ``` Indicative prices are for display purposes. They are not guaranteed and may change. Use `POST /quote` to lock in a firm rate. ## POST /quote — Create a firm quote [#post-quote--create-a-firm-quote] Lock in a rate for a specific conversion. Firm quotes have an expiration time. ### Deposit quote (GHS → USDC) [#deposit-quote-ghs--usdc] ```bash curl -X POST "https://anchor.seevcash.com/sep38/quote" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "sell_asset": "iso4217:GHS", "buy_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "sell_amount": "100", "context": "sep6" }' ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; // Note: SEP-38 is not yet built into the wallet SDK. Use fetch. const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const toml = await anchor.getInfo(); const quoteServer = toml.anchorQuoteServer; const res = await fetch(`${quoteServer}/sep38/quote`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sell_asset: 'iso4217:GHS', buy_asset: 'stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', sell_amount: '100', context: 'sep6', }), }); const quote = await res.json(); console.log(`Quote ID: ${quote.id}, expires: ${quote.expires_at}`); ``` **Response:** ```json { "id": "quote_a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d", "expires_at": "2026-08-05T01:49:15Z", "price": "15.38", "sell_asset": "iso4217:GHS", "sell_amount": "100.00", "buy_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "buy_amount": "6.50", "fee": { "total": "0.10", "asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" } } ``` ### Withdrawal quote (USDC → GHS) [#withdrawal-quote-usdc--ghs] ```bash curl -X POST "https://anchor.seevcash.com/sep38/quote" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "sell_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "buy_asset": "iso4217:GHS", "sell_amount": "6.5", "context": "sep6" }' ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; // Note: SEP-38 is not yet built into the wallet SDK. Use fetch. const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); const toml = await anchor.getInfo(); const quoteServer = toml.anchorQuoteServer; const res = await fetch(`${quoteServer}/sep38/quote`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sell_asset: 'stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', buy_asset: 'iso4217:GHS', sell_amount: '6.5', context: 'sep6', }), }); const quote = await res.json(); console.log(`Quote ID: ${quote.id}, you get: ${quote.buy_amount} GHS`); ``` **Response:** ```json { "id": "quote_b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e", "expires_at": "2026-08-05T01:49:15Z", "price": "0.065", "sell_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "sell_amount": "6.50", "buy_asset": "iso4217:GHS", "buy_amount": "100.00", "fee": { "total": "0.10", "asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" } } ``` ## Using a quote with SEP-6 [#using-a-quote-with-sep-6] Pass the `quote_id` when initiating a deposit or withdrawal to use the locked rate: ```bash curl -X GET "https://anchor.seevcash.com/sep6/deposit?\ asset_code=USDC&\ account=GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY&\ amount=6.5&\ funding_method=mobile&\ quote_id=quote_a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Use the quote_id with SEP-6 deposit via the wallet SDK const sep6 = anchor.sep6(); const deposit = await sep6.deposit({ authToken, params: { asset_code: 'USDC', account: accountKp.publicKey, amount: '6.5', funding_method: 'mobile', quote_id: 'quote_a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d', }, }); console.log("Deposit with locked quote:", deposit.id); ``` Quotes expire. If the quote has expired when you initiate the transaction, the anchor will return a `400` error. Create a new quote and retry. ## TypeScript example [#typescript-example] ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; // Note: SEP-38 quotes are not yet built into the wallet SDK. // Use the wallet SDK for authentication, then fetch for quotes. const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate using wallet SDK const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Get anchor info to find quote server URL const toml = await anchor.getInfo(); const quoteServer = toml.anchorQuoteServer; interface Quote { id: string; expires_at: string; price: string; sell_asset: string; sell_amount: string; buy_asset: string; buy_amount: string; fee: { total: string; asset: string }; } async function getIndicativePrice( sellAsset: string, buyAsset: string, sellAmount: string ) { const params = new URLSearchParams({ sell_asset: sellAsset, buy_asset: buyAsset, sell_amount: sellAmount }); const res = await fetch(`${quoteServer}/sep38/price?${params}`, { headers: { Authorization: `Bearer ${authToken}` }, }); return res.json(); } async function createQuote( sellAsset: string, buyAsset: string, sellAmount: string ): Promise { const res = await fetch(`${quoteServer}/sep38/quote`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sell_asset: sellAsset, buy_asset: buyAsset, sell_amount: sellAmount, context: 'sep6', }), }); if (!res.ok) { const error = await res.json(); throw new Error(`Quote failed: ${error.error}`); } return res.json(); } // Usage: Get a firm quote for depositing 100 GHS const quote = await createQuote( 'iso4217:GHS', 'stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', '100' ); console.log(`Quote ${quote.id}: ${quote.sell_amount} GHS → ${quote.buy_amount} USDC`); console.log(`Rate: 1 USDC = ${quote.price} GHS`); console.log(`Expires: ${quote.expires_at}`); // Use the quote with SEP-6 deposit via wallet SDK const sep6 = anchor.sep6(); const deposit = await sep6.deposit({ authToken, params: { asset_code: 'USDC', account: accountKp.publicKey, amount: quote.buy_amount, funding_method: 'mobile', quote_id: quote.id, }, }); console.log("Deposit with locked quote:", deposit.id); ``` ## Quote parameters [#quote-parameters] | Parameter | Required | Description | | ------------- | -------- | ----------------------------------------------------------- | | `sell_asset` | Yes | Asset identifier being sold | | `buy_asset` | Yes | Asset identifier being bought | | `sell_amount` | One of | Amount of sell asset (mutually exclusive with `buy_amount`) | | `buy_amount` | One of | Amount of buy asset (mutually exclusive with `sell_amount`) | | `context` | Yes | Context for the quote — `sep6` or `sep24` | ## Error responses [#error-responses] | Status | Error | Meaning | | ------ | ----------------- | ------------------------------- | | 400 | `invalid_asset` | Asset identifier not recognized | | 400 | `invalid_amount` | Amount outside supported range | | 400 | `quote_expired` | Referenced quote has expired | | 404 | `quote_not_found` | Quote ID does not exist | | 500 | `internal_error` | Anchor-side error | # SEP-24: Interactive Deposit & Withdrawal (/docs/anchor/sep24) SEP-24 provides an interactive, anchor-hosted UI for deposits and withdrawals. Unlike SEP-6 (where the wallet handles all UX), SEP-24 opens a popup or webview where the anchor collects KYC and payment details directly from the user. SeevCash supports both SEP-6 (programmatic) and SEP-24 (interactive). Choose SEP-6 for API-driven integrations and SEP-24 for simple wallet integrations that want the anchor to handle the UI. All examples on this page target **testnet** at `https://anchor.seevcash.com`. Use test keys and test values only. Never commit a real Stellar secret key — the `S...` secrets below are throwaway placeholders. ## Prerequisites: get your credentials [#prerequisites-get-your-credentials] Before you can call the interactive endpoints, you need five values. They do **not** all come from the anchor — the merchant, API key, and session values come from the **widget backend** at `https://widget-backend.seevcash.com`, while the customer ID comes from KYC. Collect them in this order: | # | Value | How to get it | Host | | - | --------------------------------------------- | ------------------------------------------------- | --------------------------------------- | | 1 | `merchant_id` | Register a merchant account → `data.id` | `widget-backend.seevcash.com` | | 2 | `api_key` (`sv_public_...`) + `sv_secret_...` | Create a merchant API key | `widget-backend.seevcash.com` | | 3 | `user_id` | Create an `ACCEPTED` SEP-12 customer | `widget-backend.seevcash.com` or anchor | | 4 | `session_id` + `session_token` | Create a widget session for that customer | `widget-backend.seevcash.com` | | 5 | SEP-10 JWT | Authenticate your Stellar keypair with the anchor | `anchor.seevcash.com` | The `api_key` here is a **merchant/widget key** (`sv_public_...` / `sv_secret_...`) created at `widget-backend.seevcash.com/api/v1/merchant/apikeys`. It is **not** the Checkout/SeevPlus key described in [Developer API Keys](/docs/developer/api-keys) — those are a different system on a different host and the anchor will reject them. Use the **public** key (`sv_public_...`) in the request body `api_key` field; keep the **secret** key (`sv_secret_...`) server-side for the `X-API-Key` header on widget-backend calls. ### Step 1 — Register and get `merchant_id` [#step-1--register-and-get-merchant_id] ```bash curl -X POST 'https://widget-backend.seevcash.com/api/v1/merchant/register' \ --header 'Content-Type: application/json' \ --data '{ "name": "Acme Payments", "legal_name": "Acme Payments Ltd.", "email": "payments@acmepay.com", "password": "" }' # Response data.id is your merchant_id. Then log in to get a 24h dashboard access_token: curl -X POST 'https://widget-backend.seevcash.com/api/v1/merchant/login' \ --header 'Content-Type: application/json' \ --data '{ "email": "payments@acmepay.com", "password": "" }' ``` ### Step 2 — Create an API key (`sv_public_...`) [#step-2--create-an-api-key-sv_public_] ```bash curl -X POST 'https://widget-backend.seevcash.com/api/v1/merchant/apikeys' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "key_type": "public", "domains": ["http://localhost:3000"] }' # The returned key (shown once) is your sv_public_... api_key. # Create a second key with "key_type": "secret" for the X-API-Key header below. ``` ### Step 3 — Create an `ACCEPTED` customer (`user_id`) [#step-3--create-an-accepted-customer-user_id] The `user_id` must be a verified SEP-12 customer, not an arbitrary internal ID. See [KYC (SEP-12)](/docs/anchor/kyc) for the full flow. The resulting customer `id` is your `user_id`. ### Step 4 — Create a widget session (`session_id`, `session_token`) [#step-4--create-a-widget-session-session_id-session_token] ```bash curl -X POST 'https://widget-backend.seevcash.com/api/v1/widget/session' \ --header 'X-API-Key: sv_secret_' \ --header 'Content-Type: application/json' \ --data '{ "user_id": "" }' # Response: { "data": { "session_id": "...", "session_token": "...", "merchant_id": "...", "expires_at": "..." } } ``` Sessions expire after **30 minutes** — mint a fresh one per flow. ### Step 5 — Authenticate with the anchor (SEP-10 JWT) [#step-5--authenticate-with-the-anchor-sep-10-jwt] Sign a SEP-10 challenge with your Stellar keypair to get the JWT used as `Authorization: Bearer` on the anchor calls below. See [Authentication (SEP-10)](/docs/anchor/authentication). Steps 1–4 produce the merchant/session fields that a **production integration must send on every request**. While experimenting against the testnet host you may be able to call the interactive endpoints with only the SEP-10 JWT (step 5), but build with all five fields from the start so your integration works unchanged in production. ## When to use SEP-24 vs SEP-6 [#when-to-use-sep-24-vs-sep-6] | | SEP-6 | SEP-24 | | -------------- | ----------------------------------- | ------------------------------- | | **UI** | Wallet handles all UI | Anchor provides popup/webview | | **KYC** | Wallet submits KYC via SEP-12 API | Anchor collects in hosted form | | **Best for** | API integrations, automated systems | Simple wallets, MoneyGram flows | | **Complexity** | More code, full control | Less code, less control | ## Base URL and endpoints [#base-url-and-endpoints] The base URL is `https://anchor.seevcash.com`. SEP-24 endpoints are served at the **root** of this host — there is no `/sep24` path prefix. Wallets that read the `stellar.toml` pick this up automatically from `TRANSFER_SERVER_SEP0024`. | Method | Path | Auth | Description | | ------ | ------------------------------------ | ---- | ----------------------------------------- | | `GET` | `/info` | No | Service info and supported assets | | `POST` | `/transactions/deposit/interactive` | JWT | Start interactive deposit | | `POST` | `/transactions/withdraw/interactive` | JWT | Start interactive withdrawal | | `GET` | `/transaction` | JWT | Get transaction status | | `GET` | `/transactions` | JWT | List transaction history | | `GET` | `/sep24/transaction/more_info` | No | Human-readable transaction summary (HTML) | ## Required request fields [#required-request-fields] In addition to the standard SEP-24 fields, the SeevCash anchor requires a set of **merchant/widget session fields** on the deposit and withdraw interactive endpoints. These identify the merchant and the KYC-verified customer behind the request. **A production integration must send all five on every deposit and withdraw request.** | Field | Type | Required | Description | | ----------------- | ------- | ---------- | -------------------------------------------------------------------------------------------------- | | `asset_code` | string | ✅ Required | Always `"USDC"` | | `merchant_id` | string | ✅ Required | Your merchant ID from registration | | `session_id` | string | ✅ Required | From `POST /api/v1/widget/session` | | `session_token` | string | ✅ Required | From `POST /api/v1/widget/session` | | `api_key` | string | ✅ Required | Your **public** API key (`sv_public_...`) | | `user_id` | string | ✅ Required | SEP-12 customer ID (must be `ACCEPTED`) | | `account` | string | Optional | Stellar public key to credit/debit. Defaults to the SEP-10 JWT's account | | `amount` | string | Optional | Amount (fiat for deposit, USDC for withdraw). If omitted, the user enters it in the interactive UI | | `collection_mode` | string | Optional | `"auto"` (default) or `"manual"` — see below | | `kyc_override` | boolean | Optional | Who hosts the KYC step — see below | | `quote_id` | string | Optional | From a SEP-38 quote (for a guaranteed rate) | | `lang` | string | Optional | Language code (default `"en"`) | **The five merchant/session fields (`merchant_id`, `session_id`, `session_token`, `api_key`, `user_id`) are required for a production integration.** Omitting any one returns `400 Bad Request` (e.g. `api_key is required`), and no transaction is created. Send them on **every** deposit and withdraw interactive request. While experimenting against the testnet host, these fields may be accepted when absent — but do not rely on that. Build and test your integration with all five present from the start so it behaves identically in production. ### Getting the session fields [#getting-the-session-fields] The session fields come from the widget backend, not the anchor. Create a short-lived session (valid 30 minutes) with a KYC-verified customer ID: ```bash curl -X POST 'https://widget-backend.seevcash.com/api/v1/widget/session' \ --header 'X-API-Key: sv_secret_' \ --header 'Content-Type: application/json' \ --data '{ "user_id": "" }' # Response: # { "data": { "session_id": "...", "session_token": "...", "merchant_id": "...", "expires_at": "..." } } ``` The `user_id` must be an **ACCEPTED** SEP-12 customer ID (see [KYC](/docs/anchor/kyc)), not an arbitrary internal identifier. The anchor treats `session_id`/`session_token` as opaque, widget-issued credentials — mint a fresh session per flow rather than reusing an expired one. ### `collection_mode` — who delivers the fiat payout [#collection_mode--who-delivers-the-fiat-payout] `collection_mode` controls who moves the off-chain fiat for a transaction. | Value | Behavior | | ------------------ | ----------------------------------------------------------------------------------------------------- | | `"auto"` (default) | The anchor disburses the Mobile Money payout itself | | `"manual"` | The anchor stands down; your backend owns the payout and notifies the anchor via `confirm-withdrawal` | If you omit it, the anchor pays out automatically. The SeevCash widget always sends `"manual"` so payout ownership lives in the widget — mirror that only if your backend performs the disbursement. ### `kyc_override` — who hosts the KYC step [#kyc_override--who-hosts-the-kyc-step] `kyc_override` changes which page the returned `url` points to. | Value | Returned `url` points to | Meaning | | ----------------- | ---------------------------- | ------------------------------------------- | | `false` (default) | The anchor's hosted KYC page | The anchor collects KYC | | `true` | The interactive widget page | The merchant/widget has already handled KYC | When `kyc_override` is `true`, the anchor assumes KYC is satisfied externally (the customer is an `ACCEPTED` SEP-12 record) and returns the interactive widget URL directly. ## Flow overview [#flow-overview] ``` Wallet Anchor User │ │ │ │ POST /transactions/deposit/interactive │ │────────────────────────►│ │ │ { url, id } │ │ │◄────────────────────────│ │ │ │ │ │ Open URL in popup │ │ │─────────────────────────────────────────────────►│ │ │ │ │ │ User fills KYC form │ │ │◄────────────────────────│ │ │ User enters MoMo info │ │ │◄────────────────────────│ │ │ │ │ Poll GET /transaction │ │ │────────────────────────►│ │ │ { status: completed } │ │ │◄────────────────────────│ │ ``` ## Interactive deposit (GHS → USDC) [#interactive-deposit-ghs--usdc] ### Step 1: Start the interactive flow [#step-1-start-the-interactive-flow] ```bash curl -X POST "https://anchor.seevcash.com/transactions/deposit/interactive" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "asset_code": "USDC", "account": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "merchant_id": "", "session_id": "", "session_token": "", "api_key": "sv_public_", "user_id": "" }' ``` ```typescript import { Wallet, SigningKeypair, StellarConfiguration } from '@stellar/typescript-wallet-sdk'; const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.TestNet() }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); // Throwaway test secret — replace with your own, never commit a real secret. const accountKp = SigningKeypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate (SEP-10) const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Start interactive deposit (SEP-24) with the merchant/session fields const sep24 = anchor.sep24(); const { url, id } = await sep24.deposit({ assetCode: 'USDC', authToken, destinationAccount: accountKp.publicKey, extraFields: { merchant_id: '', session_id: '', session_token: '', api_key: 'sv_public_', user_id: '', }, }); // Open url in popup/webview for the user to complete console.log("Interactive URL:", url); console.log("Transaction ID:", id); ``` **Response:** ```json { "type": "interactive_customer_info_needed", "url": "https://exchange.seevcash.com/interactive?token=", "id": "txn-456" } ``` The `url` host depends on `kyc_override` (see above): with `kyc_override: true` it points to the interactive widget page; with the default `false` it points to the anchor's hosted KYC page. The session context is carried inside the `token` JWT. ### Step 2: Open the URL [#step-2-open-the-url] The wallet opens the returned `url` in a popup, iframe, or webview. The user completes: 1. KYC information (name, phone, ID) — unless handled externally via `kyc_override` 2. Mobile money details (phone number, provider) 3. Amount confirmation ### Step 3: Poll for completion [#step-3-poll-for-completion] ```bash curl -X GET "https://anchor.seevcash.com/transaction?id=txn-456" \ -H "Authorization: Bearer " ``` ```typescript const sep24 = anchor.sep24(); const { transaction } = await sep24.getTransactionBy({ authToken, id: 'txn-456', }); console.log('Status:', transaction.status); ``` **Response (in progress):** ```json { "transaction": { "id": "txn-456", "kind": "deposit", "status": "pending_user_transfer_start", "more_info_url": "https://anchor.seevcash.com/sep24/transaction/more_info?id=txn-456" } } ``` **Response (completed):** ```json { "transaction": { "id": "txn-456", "kind": "deposit", "status": "completed", "amount_in": "100.00", "amount_out": "6.50", "stellar_transaction_id": "abc123def...", "completed_at": "2026-08-05T01:00:00Z" } } ``` ## Interactive withdrawal (USDC → GHS) [#interactive-withdrawal-usdc--ghs] ### Step 1: Start the interactive flow [#step-1-start-the-interactive-flow-1] ```bash curl -X POST "https://anchor.seevcash.com/transactions/withdraw/interactive" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "asset_code": "USDC", "account": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "merchant_id": "", "session_id": "", "session_token": "", "api_key": "sv_public_", "user_id": "" }' ``` ```typescript import { Wallet, SigningKeypair, StellarConfiguration } from '@stellar/typescript-wallet-sdk'; const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.TestNet() }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); // Throwaway test secret — replace with your own, never commit a real secret. const accountKp = SigningKeypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate (SEP-10) const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Start interactive withdrawal (SEP-24) with the merchant/session fields const sep24 = anchor.sep24(); const { url, id } = await sep24.withdraw({ assetCode: 'USDC', authToken, extraFields: { merchant_id: '', session_id: '', session_token: '', api_key: 'sv_public_', user_id: '', }, }); console.log("Interactive URL:", url); console.log("Transaction ID:", id); ``` **Response:** ```json { "type": "interactive_customer_info_needed", "url": "https://exchange.seevcash.com/interactive?token=", "id": "txn-789" } ``` ### Step 2: User completes the form [#step-2-user-completes-the-form] In the interactive UI, the user: 1. Enters the amount of USDC to withdraw 2. Provides mobile money payout details 3. Confirms the exchange rate ### Step 3: Send USDC [#step-3-send-usdc] After the interactive form is complete, the transaction status changes to `pending_user_transfer_start`. The wallet must send USDC to the anchor at `withdraw_anchor_account` with the `withdraw_memo` from the transaction: ```typescript import { Wallet, SigningKeypair, StellarConfiguration } from '@stellar/typescript-wallet-sdk'; import { Asset, TransactionBuilder, Networks, Horizon, Operation, Memo, } from '@stellar/stellar-sdk'; const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.TestNet() }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); // Throwaway test secret — replace with your own, never commit a real secret. const accountKp = SigningKeypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Get transaction details for withdraw_anchor_account and memo const sep24 = anchor.sep24(); const { transaction } = await sep24.getTransactionBy({ authToken, id: 'txn-789' }); // Testnet Horizon + testnet network passphrase — keep these in sync with the Wallet network above. const server = new Horizon.Server('https://horizon-testnet.stellar.org'); const account = await server.loadAccount(accountKp.publicKey); const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET, }) .addOperation(Operation.payment({ destination: transaction.withdraw_anchor_account, asset: new Asset('USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'), amount: transaction.amount_in, })) .addMemo(Memo.text(transaction.withdraw_memo)) .setTimeout(300) .build(); tx.sign(accountKp); await server.submitTransaction(tx); ``` Keep the network consistent end-to-end: a testnet `Wallet` must use testnet Horizon (`https://horizon-testnet.stellar.org`) and `Networks.TESTNET`. The USDC asset issuer also differs between testnet and mainnet — read it from the transaction / `stellar.toml` rather than hardcoding. ### Step 4: Poll for completion [#step-4-poll-for-completion] The anchor detects the incoming USDC payment, converts to GHS, and pays out via Mobile Money (when `collection_mode` is `auto`). ```bash curl -X GET "https://anchor.seevcash.com/transaction?id=txn-789" \ -H "Authorization: Bearer " ``` ```typescript const sep24 = anchor.sep24(); const { transaction } = await sep24.getTransactionBy({ authToken, id: 'txn-789', }); console.log('Status:', transaction.status); ``` ```json { "transaction": { "id": "txn-789", "kind": "withdrawal", "status": "completed", "amount_in": "50.00", "amount_out": "665.00", "stellar_transaction_id": "xyz789...", "external_transaction_id": "PROV-456", "completed_at": "2026-08-05T01:05:00Z" } } ``` ## Transaction status flow [#transaction-status-flow] ``` Deposit: incomplete → pending_user_transfer_start → pending_anchor → pending_stellar → completed Withdraw: incomplete → pending_user_transfer_start → pending_external → pending_payout_confirmation → completed ``` | Status | Meaning | | ----------------------------- | ----------------------------------------------------------------- | | `incomplete` | Interactive UI not yet completed | | `pending_user_transfer_start` | Deposit: awaiting MoMo approval. Withdraw: awaiting USDC transfer | | `pending_anchor` | Fiat received, preparing crypto delivery | | `pending_stellar` | USDC sent, awaiting blockchain confirmation | | `pending_external` | USDC received, initiating fiat payout | | `pending_payout_confirmation` | Mobile Money disbursement in progress | | `completed` | Transaction finished successfully | | `failed` | Something went wrong (see `message`) | | `expired` | Customer didn't complete within the window | ## TypeScript integration (full example) [#typescript-integration-full-example] ```typescript import { Wallet, SigningKeypair, StellarConfiguration } from '@stellar/typescript-wallet-sdk'; const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const wallet = new Wallet({ stellarConfiguration: StellarConfiguration.TestNet() }); const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); // Throwaway test secret — replace with your own, never commit a real secret. const accountKp = SigningKeypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // 1. Authenticate (SEP-10) const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // 2. Start interactive deposit (SEP-24) const sep24 = anchor.sep24(); const { url, id } = await sep24.deposit({ assetCode: 'USDC', authToken, destinationAccount: accountKp.publicKey, extraFields: { merchant_id: '', session_id: '', session_token: '', api_key: 'sv_public_', user_id: '', }, }); // 3. Open the URL in a popup/webview window.open(url, '_blank', 'width=450,height=700'); // 4. Poll for status using the watcher const watcher = sep24.watcher(); const { stop } = watcher.watchOneTransaction({ authToken, assetCode: 'USDC', id, onMessage: (txn) => console.log('Status:', txn.status), onSuccess: (txn) => { console.log('Deposit complete! Stellar TX:', txn.stellar_transaction_id); stop(); }, onError: (err) => { console.error('Error:', err); stop(); }, }); ``` ## SEP-6 vs SEP-24 comparison [#sep-6-vs-sep-24-comparison] | Feature | SEP-6 (Programmatic) | SEP-24 (Interactive) | | ----------------------- | -------------------------------- | ------------------------- | | KYC collection | Wallet → SEP-12 API | Anchor's hosted form | | Payment details | Wallet shows instructions | Anchor's hosted form | | Mobile money trigger | Anchor auto-collects (auto mode) | Anchor handles in form | | Integration effort | More code | Less code (just open URL) | | User experience control | Wallet controls UX | Anchor controls UX | | MoneyGram support | ❌ | ✅ | ## Related [#related] * [Deposit via SEP-6](/docs/anchor/deposit) — Programmatic deposit without interactive UI * [Withdrawal via SEP-6](/docs/anchor/withdraw) — Programmatic withdrawal * [Authentication](/docs/anchor/authentication) — Required before starting any flow * [KYC (SEP-12)](/docs/anchor/kyc) — Produce the `ACCEPTED` customer ID used as `user_id` * [Stellar SEP-24 Specification](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md) # SEP-31: Cross-Border Payments (/docs/anchor/sep31) SEP-31 defines a business-to-business **"Direct Payment"** API. A **Sending Anchor** (a licensed partner business) delivers **USDC on Stellar** to the SeevCash anchor, and SeevCash — the **Receiving Anchor** — pays out the equivalent **Ghana Cedis (GHS)** to a **Receiving Client** via Mobile Money. Only USDC is received on-chain. GHS is off-chain fiat — delivered through Mobile Money and never issued as a Stellar asset. The GHS payout amount is either locked with a [SEP-38 quote](/docs/anchor/quotes) (`stellar:USDC` → `iso4217:GHS`) or computed at the live rate at payout time. ## Roles [#roles] | Role | Who | In this API | | -------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- | | **Sending Anchor** | Your business / partner | The API client. Authenticates via SEP-10, registers customers, creates transactions, submits the USDC payment. | | **Receiving Anchor** | SeevCash (`anchor.seevcash.com`) | Receives USDC on Stellar, pays out GHS via Mobile Money. | | **Sending Client** | The partner's end user | The person funding the payment (off-platform). | | **Receiving Client** | The recipient in Ghana | Receives GHS to their Mobile Money wallet. | ## End-to-end flow [#end-to-end-flow] ``` Sending Anchor SeevCash (Receiving Anchor) │ │ │ 0. GET /.well-known/stellar.toml │ │────────────────────────────────────────────────────►│ SEP-1 discovery │ 1. GET /sep31/info │ │────────────────────────────────────────────────────►│ assets, fees, fields │ 2. SEP-10 auth (GET /auth → sign → POST /auth) │ │◄───────────────────────────────────────────────────►│ → JWT │ 3. SEP-12 PUT /customer (sender + receiver) │ │────────────────────────────────────────────────────►│ → customer ids, ACCEPTED │ 4. SEP-38 POST /quote (optional, locks the rate) │ │────────────────────────────────────────────────────►│ → quote_id │ 5. POST /sep31/transactions │ │────────────────────────────────────────────────────►│ → id + stellar_account_id + memo │ 6. Submit USDC payment on Stellar (with memo) │ │─────────────────────────► Stellar ─────────────────►│ anchor matches by memo │ 7. GET /sep31/transactions/:id (or callback) │ │◄───────────────────────────────────────────────────►│ pending_* → completed │ │ GHS paid out via Mobile Money ``` ## Endpoints [#endpoints] | Method | Path | Auth | Description | | ------ | ---------------------------------- | ---- | ------------------------------------------------------------------------ | | GET | `/sep31/info` | — | Supported receive assets, fees, limits, funding methods, required fields | | POST | `/sep31/transactions` | JWT | Initiate a receive transaction | | GET | `/sep31/transactions/:id` | JWT | Poll transaction status | | PATCH | `/sep31/transactions/:id` | JWT | Update per-transaction fields (recovery) | | PUT | `/sep31/transactions/:id/callback` | JWT | Register a status-change callback URL | All endpoints except `/sep31/info` require a valid SEP-10 JWT in the `Authorization: Bearer ` header. A missing or invalid token returns **403 Forbidden**. Sender and receiver must be registered via [SEP-12 KYC](/docs/anchor/kyc) and reach `ACCEPTED` before a transaction can be created. *** ## Step 0 — Discover the anchor (SEP-1) [#step-0--discover-the-anchor-sep-1] Read the anchor's [`stellar.toml`](/docs/anchor/stellar-toml) to get the endpoint URLs and the signing key used to verify callbacks. ```bash curl https://anchor.seevcash.com/.well-known/stellar.toml ``` ```toml NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" WEB_AUTH_ENDPOINT = "https://anchor.seevcash.com/auth" # SEP-10 KYC_SERVER = "https://anchor.seevcash.com/sep12" # SEP-12 DIRECT_PAYMENT_SERVER = "https://anchor.seevcash.com/sep31" # SEP-31 ANCHOR_QUOTE_SERVER = "https://anchor.seevcash.com/sep38" # SEP-38 SIGNING_KEY = "GA2E...MPVR" # verifies SEP-31 callback signatures ``` *** ## Step 1 — GET /info: supported assets [#step-1--get-info-supported-assets] Advertises what the anchor can receive, the fee model, limits, SEP-38 quote support, delivery (funding) methods, and the SEP-12 customer types. ```bash curl https://anchor.seevcash.com/sep31/info ``` ```json { "receive": { "USDC": { "enabled": true, "quotes_supported": true, "quotes_required": false, "funding_methods": ["mobile_money"], "fee_fixed": 0.5, "fee_percent": 1.0, "min_amount": 10, "max_amount": 10000, "sep12": { "sender": { "types": { "sep31_sender": { "description": "The person initiating the payment" } } }, "receiver": { "types": { "sep31_receiver": { "description": "The person receiving the payment" } } } }, "fields": { "transaction": { "receiver_routing_number": { "description": "Mobile money provider code for the GHS payout", "optional": false }, "receiver_account_number": { "description": "Mobile money account number for the GHS payout", "optional": false }, "type": { "description": "Type of payout (e.g., mobile_money, bank_transfer)", "optional": true } } } } } } ``` | Field | Meaning | | --------------------------- | --------------------------------------------------------------------------------------------- | | `quotes_supported` | `true` — the anchor can deliver GHS in exchange for USDC via a SEP-38 quote. | | `quotes_required` | `false` — a quote is optional. Without one, GHS is delivered at the live rate at payout time. | | `funding_methods` | How the anchor delivers the off-chain asset. GHS is paid out via `mobile_money`. | | `fee_fixed` / `fee_percent` | Fee in USDC = `fee_fixed + amount * fee_percent / 100`. | | `min_amount` / `max_amount` | Accepted USDC amount range. | | `fields.transaction` | Per-transaction rails data the receiver payout needs (Mobile Money number + provider). | `receive` lists only **USDC** because that is the asset received on Stellar. The GHS payout is an off-chain Mobile Money disbursement modeled as `iso4217:GHS` in SEP-38, keeping SEP-31 and SEP-38 consistent. *** ## Step 2 — Authenticate (SEP-10) [#step-2--authenticate-sep-10] Obtain a JWT for the Sending Anchor's Stellar account. See [SEP-10: Authentication](/docs/anchor/authentication) for the full flow. ```bash # 1. Request a challenge curl "https://anchor.seevcash.com/auth?account=G" # → { "transaction": "", "network_passphrase": "..." } # 2. Sign the challenge, then submit it curl -X POST "https://anchor.seevcash.com/auth" \ -H "Content-Type: application/json" \ -d '{ "transaction": "" }' # → { "token": "" } ``` Use the returned JWT as `Authorization: Bearer ` on every call below. *** ## Step 3 — Register sender and receiver (SEP-12 KYC) [#step-3--register-sender-and-receiver-sep-12-kyc] Both customers must exist and reach `ACCEPTED`. See [SEP-12: KYC](/docs/anchor/kyc). The receiver must supply Mobile Money payout details (number + provider) so the GHS disbursement can be made. ```bash curl -X PUT "https://anchor.seevcash.com/sep12/customer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "type": "sep31_sender", "first_name": "Alice", "last_name": "Sender", "email_address": "alice@example.com" }' # → { "id": "c1a2...sender-id" } ``` ```bash curl -X PUT "https://anchor.seevcash.com/sep12/customer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "type": "sep31_receiver", "first_name": "Bob", "last_name": "Receiver", "email_address": "bob@example.com", "mobile_number": "+233241234567", "mobile_money_provider": "MTN" }' # → { "id": "c3b4...receiver-id" } ``` ```bash curl "https://anchor.seevcash.com/sep12/customer?id=c3b4...receiver-id" \ -H "Authorization: Bearer " # → { "id": "c3b4...receiver-id", "status": "ACCEPTED", "provided_fields": { ... } } ``` `POST /sep31/transactions` is rejected unless **both** `sender_id` and `receiver_id` resolve to customers with status `ACCEPTED`. See [Customer info needed](#error-responses) below. *** ## Step 4 — Get a firm quote (SEP-38, optional) [#step-4--get-a-firm-quote-sep-38-optional] Lock the USDC → GHS rate before creating the transaction. Skip this to use the live rate at payout time. See [Quotes (SEP-38)](/docs/anchor/quotes). ```bash curl -X POST "https://anchor.seevcash.com/sep38/quote" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "sell_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "buy_asset": "iso4217:GHS", "sell_amount": "100", "context": "sep31" }' ``` ```json { "id": "quote_1733000000_ab12cd34", "expires_at": "2026-10-05T11:15:00Z", "total_price": "13.80", "price": "13.38", "sell_amount": "100.00", "sell_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "buy_amount": "1380.00", "buy_asset": "iso4217:GHS", "fee": { "total": "3.00", "asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" } } ``` A quoted transaction **expires with the quote**. If the USDC payment is not received before `expires_at`, the transaction moves to `expired` and must be recreated with a fresh quote. *** ## Step 5 — POST /transactions: initiate the receive [#step-5--post-transactions-initiate-the-receive] There are three valid request shapes. All return the **same** response: the Stellar account and memo to pay to. The `quote_id` and `destination_asset` fields are **optional**. GHS delivered at the live rate at payout time. ```bash curl -X POST "https://anchor.seevcash.com/sep31/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": "100", "asset_code": "USDC", "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "sender_id": "c1a2...sender-id", "receiver_id": "c3b4...receiver-id", "funding_method": "mobile_money", "fields": { "transaction": { "receiver_account_number": "0241234567", "receiver_routing_number": "MTN" } } }' ``` Rate locked to the SEP-38 quote. Add `quote_id`. ```bash curl -X POST "https://anchor.seevcash.com/sep31/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": "100", "asset_code": "USDC", "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "quote_id": "quote_1733000000_ab12cd34", "sender_id": "c1a2...sender-id", "receiver_id": "c3b4...receiver-id", "funding_method": "mobile_money", "fields": { "transaction": { "receiver_account_number": "0241234567", "receiver_routing_number": "MTN" } } }' ``` Estimated rate via `destination_asset` (no firm quote). ```bash curl -X POST "https://anchor.seevcash.com/sep31/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": "100", "asset_code": "USDC", "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "destination_asset": "iso4217:GHS", "sender_id": "c1a2...sender-id", "receiver_id": "c3b4...receiver-id", "funding_method": "mobile_money", "fields": { "transaction": { "receiver_account_number": "0241234567", "receiver_routing_number": "MTN" } } }' ``` ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "stellar_account_id": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "stellar_memo_type": "text", "stellar_memo": "550e8400e29b41d4a716446655" } ``` ### Request parameters [#request-parameters] | Field | Required | Description | | -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `amount` | yes | USDC amount the Sending Anchor will send. Must be within `min_amount`/`max_amount`. | | `asset_code` | yes | `USDC`. Must match an entry in `/info`. | | `asset_issuer` | no | USDC issuer. A missing/invalid value is normalized to the correct Circle issuer for the active network. | | `sender_id` | yes | SEP-12 id of the sending customer (must be `ACCEPTED`). | | `receiver_id` | yes | SEP-12 id of the receiving customer (must be `ACCEPTED`). | | `quote_id` | no | A firm SEP-38 quote id. If present, amounts are validated against and aligned to the quote. | | `destination_asset` | no | For the indicative flow. Must be `iso4217:GHS`. | | `funding_method` | no | Advisory. Should be `mobile_money`; other values are accepted with a warning (delivery is always Mobile Money). | | `fields.transaction` | recommended | Mobile Money payout rails. If supplied, required fields advertised in `/info` must be complete. May also be provided via the receiver's SEP-12 record. | The `stellar_memo` is the transaction id (UUID, dashes removed) truncated to 28 characters — a Stellar **text** memo. The Sending Anchor **must** attach this exact memo to the USDC payment; the anchor matches incoming payments **by memo only**. ### Quote validation (when `quote_id` is used) [#quote-validation-when-quote_id-is-used] The anchor rejects the request if the quote does not line up with the transaction: | Condition | Response | | ------------------------------------------- | -------------------------------------------------------------------------- | | Quote not found / expired | `400 { "error": "quote_id ... not found or expired" }` | | `quote.sell_asset` ≠ transaction USDC asset | `400 { "error": "quote sell_asset ... does not match transaction asset" }` | | `quote.sell_amount` ≠ `amount` | `400 { "error": "quote sell_amount ... does not match requested amount" }` | On success the stored amounts are aligned to the quote: `amount_in = sell_amount`, `amount_out = buy_amount`, `amount_fee = fee.total`, `amount_fee_asset = fee.asset`. *** ## Step 6 — Submit the USDC payment on Stellar [#step-6--submit-the-usdc-payment-on-stellar] Off-protocol. The Sending Anchor builds a Stellar **payment** operation and submits it to Horizon: * **Destination:** `stellar_account_id` from the 201 response * **Asset:** `USDC` (the issuer from `/info` / your `asset_issuer`) * **Amount:** the `amount` from the request * **Memo:** `text` memo equal to `stellar_memo` The anchor's payment monitor detects the payment, matches it by memo, and begins the GHS payout. *** ## Step 7 — GET /transactions/:id: poll status [#step-7--get-transactionsid-poll-status] ```bash curl "https://anchor.seevcash.com/sep31/transactions/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer " ``` `amount_out` is omitted on the no-quote path until the payment arrives (it is only known once the GHS amount is computed). With a `quote_id` it is present from creation. ```json { "transaction": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "pending_sender", "amount_in": "100.00", "amount_in_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "amount_out_asset": "iso4217:GHS", "amount_fee": "1.50", "amount_fee_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "fee_details": { "total": "1.50", "asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "details": [ { "name": "Anchor fee", "amount": "1.50" } ] }, "stellar_account_id": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "stellar_memo": "550e8400e29b41d4a716446655", "stellar_memo_type": "text", "started_at": "2026-10-05T11:00:00Z", "updated_at": "2026-10-05T11:00:00Z", "sender_id": "c1a2...sender-id", "receiver_id": "c3b4...receiver-id" } } ``` ```json { "transaction": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "completed", "amount_in": "100.00", "amount_in_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "amount_out": "1380.00", "amount_out_asset": "iso4217:GHS", "amount_fee": "1.50", "amount_fee_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "fee_details": { "total": "1.50", "asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "details": [ { "name": "Anchor fee", "amount": "1.50" } ] }, "stellar_account_id": "GA2E5D24D2LCGVA3SVVKDP64OTN2M2NLUWRDYINB352HW35ABZ66MPVR", "stellar_memo": "550e8400e29b41d4a716446655", "stellar_memo_type": "text", "stellar_transaction_id": "3389e9f0f1a7...", "external_transaction_id": "MOMO-REF-123456", "started_at": "2026-10-05T11:00:00Z", "updated_at": "2026-10-05T11:02:30Z", "completed_at": "2026-10-05T11:02:30Z", "sender_id": "c1a2...sender-id", "receiver_id": "c3b4...receiver-id" } } ``` | Field | Meaning | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount_in` / `amount_in_asset` | USDC received on Stellar, in [SEP-38 asset format](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0038.md#asset-identification-format). | | `amount_out` / `amount_out_asset` | GHS delivered to the receiver (`iso4217:GHS`). | | `amount_fee` / `amount_fee_asset` | Fee (deprecated in the spec in favor of `fee_details`, still returned). | | `fee_details` | Structured fee breakdown (`total`, `asset`, `details[]`). | | `stellar_transaction_id` | Hash of the on-chain USDC payment the anchor matched. | | `external_transaction_id` | Mobile Money reference for the GHS payout — used to reconcile on-chain receipt against off-chain disbursement. | A non-existent id returns **404** `{ "error": "Transaction not found" }`. *** ## Step 8 — Status callbacks (optional, instead of polling) [#step-8--status-callbacks-optional-instead-of-polling] Register a URL and the anchor will POST the full transaction object on every status change — signed so you can verify it came from the anchor. ```bash curl -X PUT "https://anchor.seevcash.com/sep31/transactions/550e8400-e29b-41d4-a716-446655440000/callback" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "url": "https://partner.example.com/sep31/callback" }' # → 204 No Content ``` ```http POST /sep31/callback HTTP/1.1 Host: partner.example.com Content-Type: application/json Signature: t=1733000000, s= X-Stellar-Signature: t=1733000000, s= { "transaction": { "...": "same shape as GET /transactions/:id" } } ``` **Verify the signature.** The signature covers the string `..` and is signed with the anchor's `SIGNING_KEY` (from `stellar.toml`). Reject callbacks whose timestamp is older than \~2 minutes to prevent replay. The deprecated `X-Stellar-Signature` header carries the same value for backward compatibility. *** ## Recovery — PATCH /transactions/:id [#recovery--patch-transactionsid] If the Mobile Money payout cannot proceed because the per-transaction rails data is missing or invalid, the transaction moves to `pending_transaction_info_update` with a `required_info_updates` object describing exactly what to resend. ```json { "transaction": { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "pending_transaction_info_update", "required_info_message": "Missing receiver mobile-money payout details (phone/provider) — cannot deliver GHS", "required_info_updates": { "transaction": { "receiver_account_number": { "description": "Mobile money account number for the GHS payout" } } } } } ``` ```bash curl -X PATCH "https://anchor.seevcash.com/sep31/transactions/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "fields": { "transaction": { "receiver_account_number": "0241234567" } } }' ``` Once the corrected fields validate, the transaction returns to `pending_receiver` and the payout is retried automatically. *** ## Transaction statuses [#transaction-statuses] | Status | Meaning | | --------------------------------- | -------------------------------------------------------------------------------------------------- | | `pending_sender` | Awaiting the USDC payment on Stellar. | | `pending_receiver` | USDC received and matched; payout being prepared. | | `pending_external` | GHS submitted to the Mobile Money network; awaiting confirmation. | | `pending_transaction_info_update` | Rails data invalid/missing — resend via `PATCH` (see [Recovery](#recovery--patch-transactionsid)). | | `completed` | GHS delivered to the receiver via Mobile Money. | | `expired` | A quoted transaction whose quote lapsed before the USDC payment arrived. | | `error` | Unrecoverable failure; contact the anchor. | ``` pending_sender → pending_receiver → pending_external → completed │ └▶ pending_transaction_info_update ──(PATCH)──▶ pending_receiver expired ← quote lapsed before funding error ← unrecoverable failure ``` *** ## Error responses [#error-responses] | Status | Body | When | | ------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | 403 | — | Missing or invalid SEP-10 JWT. | | 400 | `{ "error": "customer_info_needed", "type": "sep31_sender", "message": "..." }` | A customer is missing or not `ACCEPTED`. Re-run SEP-12 for that `type`, then retry. | | 400 | `{ "error": "transaction_info_needed", "fields": { "transaction": { ... } } }` | Required per-transaction `fields` were incomplete. Resend with the listed fields. | | 400 | `{ "error": "Amount must be between 10 and 10000" }` | `amount` outside the configured range. | | 400 | `{ "error": "quote_id ... not found or expired" }` | Supplied `quote_id` is invalid/expired. | | 404 | `{ "error": "Transaction not found" }` | Unknown transaction id on `GET`/`PATCH`/callback. | *** ## Reconciliation [#reconciliation] Each completed transaction links an **on-chain receipt** to an **off-chain disbursement**: * `stellar_transaction_id` — the Horizon hash of the USDC payment received. * `external_transaction_id` — the Mobile Money provider reference for the GHS payout. Match these against your own ledger. During testing the Mobile Money leg is **simulated** (no live payout), and `external_transaction_id` is prefixed `SIMULATED_WTH_`. ## Related [#related] * [SEP-10: Authentication](/docs/anchor/authentication) — Obtain the JWT. * [SEP-12: KYC](/docs/anchor/kyc) — Register sender and receiver. * [Quotes (SEP-38)](/docs/anchor/quotes) — Price / lock the USDC → GHS conversion. * [stellar.toml](/docs/anchor/stellar-toml) — Discovery and signing key. * [Stellar SEP-31 Specification](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0031.md) # SEP-1: Stellar Info File (stellar.toml) (/docs/anchor/stellar-toml) SEP-1 defines how wallets discover your anchor. The `stellar.toml` file is the entry point for every Stellar integration — it tells wallets what services you offer, where the APIs live, and what assets are supported. ## Fetching the TOML [#fetching-the-toml] ```bash curl https://anchor.seevcash.com/.well-known/stellar.toml ``` ```typescript import { Wallet } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); // The wallet SDK resolves the stellar.toml automatically const toml = await anchor.getInfo(); ``` Every compliant wallet fetches this automatically when it encounters the SeevCash anchor. ## Key fields [#key-fields] | Field | Value | Purpose | | ------------------------- | ----------------------------------- | -------------------------------------------------------- | | `TRANSFER_SERVER` | `https://anchor.seevcash.com` | SEP-6 programmatic deposit/withdrawal API | | `WEB_AUTH_ENDPOINT` | `https://anchor.seevcash.com/auth` | SEP-10 authentication | | `KYC_SERVER` | `https://anchor.seevcash.com` | SEP-12 KYC API | | `TRANSFER_SERVER_SEP0024` | `https://anchor.seevcash.com` | SEP-24 interactive flows | | `DIRECT_PAYMENT_SERVER` | `https://anchor.seevcash.com/sep31` | SEP-31 cross-border payments | | `ANCHOR_QUOTE_SERVER` | `https://anchor.seevcash.com` | SEP-38 quotes | | `SIGNING_KEY` | `G...` | Public key for verifying SEP-10 challenges and callbacks | | `NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Stellar network | ## Currencies [#currencies] The TOML declares which assets the anchor supports. Only USDC is issued on Stellar (mainnet). GHS is off-chain fiat and is **not** declared as a currency here — it is offered as `iso4217:GHS` through SEP-38 quotes and SEP-31 payouts. ```toml [[CURRENCIES]] code = "USDC" issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" status = "test" is_asset_anchored = true anchor_asset_type = "fiat" anchor_asset = "USD" desc = "USD Coin issued by Circle" ``` On testnet the anchor advertises the testnet USDC issuer (`GBBD47IF...`) and `status = "test"`. On the public network it serves the mainnet Circle issuer (`GA5ZSEJY...`) and `status = "live"`. The issuer is network-pinned by the anchor, so it always matches the active network. ## Using SEP-1 in your integration [#using-sep-1-in-your-integration] ```bash # Discover all endpoints curl -s https://anchor.seevcash.com/.well-known/stellar.toml | grep -E "TRANSFER_SERVER|WEB_AUTH|KYC_SERVER|SIGNING_KEY" ``` ```typescript import { Wallet } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const toml = await anchor.getInfo(); console.log(toml.transferServer); // SEP-6 base URL console.log(toml.webAuthEndpoint); // SEP-10 auth console.log(toml.kycServer); // SEP-12 console.log(toml.anchorQuoteServer); // SEP-38 console.log(toml.signingKey); // For verifying callbacks ``` ## Why it matters [#why-it-matters] Without SEP-1, no wallet can find your anchor. The typical wallet flow starts with: 1. User enters the anchor's domain (e.g. `anchor.seevcash.com`) 2. Wallet fetches `/.well-known/stellar.toml` 3. Wallet reads the available SEPs and supported assets 4. Wallet proceeds with SEP-10 authentication using `WEB_AUTH_ENDPOINT` The `stellar.toml` must be served over HTTPS with proper CORS headers (`Access-Control-Allow-Origin: *`). Wallets will reject anchors that don't serve over HTTPS. ## Organization info [#organization-info] The TOML also contains organization details that wallets display to users: ```toml [DOCUMENTATION] ORG_NAME = "SeevCash" ORG_DBA = "SeevCash" ORG_URL = "https://seevcash.com" ORG_DESCRIPTION = "Fiat on/off-ramp for Stellar via Ghana Mobile Money" ORG_OFFICIAL_EMAIL = "support@seevcash.com" ``` ## Related [#related] * [Authentication (SEP-10)](/docs/anchor/authentication) — Next step after discovery * [Stellar SEP-1 Specification](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0001.md) # Transactions (/docs/anchor/transactions) The SeevCash anchor tracks every deposit and withdrawal as a transaction with a defined lifecycle. This page covers querying transactions, understanding statuses, configuring callbacks, and using the more\_info page. ## Endpoints [#endpoints] | Method | Path | Description | | ------ | -------------------- | --------------------------------------------- | | GET | `/sep6/transaction` | Get a single transaction by ID or external ID | | GET | `/sep6/transactions` | List transactions for an account | All endpoints require a valid SEP-10 JWT token. ## Query a single transaction [#query-a-single-transaction] ```bash curl -X GET "https://anchor.seevcash.com/sep6/transaction?id=txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Get a single transaction by ID const sep6 = anchor.sep6(); const { transaction } = await sep6.getTransactionBy({ authToken, id: 'txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c', }); console.log("Status:", transaction.status); ``` ### Query parameters [#query-parameters] | Parameter | Description | | ------------------------- | ------------------------------------- | | `id` | Anchor transaction ID | | `stellar_transaction_id` | Stellar network transaction hash | | `external_transaction_id` | External (MoMo) transaction reference | **Response:** ```json { "transaction": { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "kind": "deposit", "status": "completed", "status_eta": null, "amount_in": "100.00", "amount_in_asset": "iso4217:GHS", "amount_out": "6.50", "amount_out_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "amount_fee": "0.10", "amount_fee_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "started_at": "2026-08-05T01:15:00Z", "completed_at": "2026-08-05T01:19:30Z", "stellar_transaction_id": "abc123def456...", "external_transaction_id": "MOMO-REF-12345", "more_info_url": "https://anchor.seevcash.com/sep6/transaction/more_info?id=txn_8f3a4b2c", "message": null } } ``` ## List transactions [#list-transactions] ```bash curl -X GET "https://anchor.seevcash.com/sep6/transactions?\ asset_code=USDC&\ account=GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY&\ limit=10" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Get all transactions for USDC const sep6 = anchor.sep6(); const { transactions } = await sep6.getTransactionsForAsset({ authToken, assetCode: 'USDC', }); transactions.forEach((tx) => console.log(`${tx.id}: ${tx.status}`)); ``` ### Query parameters [#query-parameters-1] | Parameter | Required | Description | | --------------- | -------- | ------------------------------------------ | | `asset_code` | Yes | Filter by asset code (`USDC`) | | `account` | Yes | Stellar account | | `no_older_than` | No | ISO 8601 date — exclude older transactions | | `limit` | No | Max results (default 10) | | `kind` | No | Filter by `deposit` or `withdrawal` | | `paging_id` | No | Cursor for pagination | **Response:** ```json { "transactions": [ { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "kind": "deposit", "status": "completed", "amount_in": "100.00", "amount_out": "6.50", "started_at": "2026-08-05T01:15:00Z", "completed_at": "2026-08-05T01:19:30Z" }, { "id": "txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "kind": "withdrawal", "status": "pending_anchor", "amount_in": "6.50", "amount_out": "100.00", "started_at": "2026-08-05T01:20:00Z" } ] } ``` ## Transaction status lifecycle [#transaction-status-lifecycle] ### Deposit statuses [#deposit-statuses] ``` incomplete │ ▼ pending_user_transfer_start ──► Customer must send/approve MoMo payment │ ▼ pending_anchor ──► Anchor received GHS, sending USDC │ ├──────────────────────────────────────────┐ ▼ ▼ completed error ``` ### Withdrawal statuses [#withdrawal-statuses] ``` incomplete │ ▼ pending_user_transfer_start ──► Customer must send USDC to anchor │ ▼ pending_anchor ──► Anchor received USDC, processing payout │ ▼ pending_external ──► GHS payout submitted to MoMo network │ ├──────────────────────────────────────────┐ ▼ ▼ completed error ``` ### All possible statuses [#all-possible-statuses] | Status | Meaning | | -------------------------------- | ------------------------------------------------ | | `incomplete` | Transaction created but not ready for processing | | `pending_user_transfer_start` | Waiting for user to send/approve payment | | `pending_user_transfer_complete` | User payment received, awaiting confirmation | | `pending_anchor` | Anchor is processing the transaction | | `pending_stellar` | Waiting for Stellar network confirmation | | `pending_external` | Waiting for external (MoMo) network confirmation | | `completed` | Transaction finished successfully | | `expired` | Transaction expired before completion | | `error` | Transaction failed — see `message` field | | `refunded` | Transaction was refunded to the customer | ## Polling strategy [#polling-strategy] Poll every 5–10 seconds during active transactions. Back off to 30–60 seconds after 2 minutes. Stop polling once a terminal status is reached (`completed`, `error`, `expired`, `refunded`). ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Use the built-in watcher for automatic polling with backoff const sep6 = anchor.sep6(); const watcher = sep6.watcher(); const { stop, refresh } = watcher.watchOneTransaction({ authToken, assetCode: 'USDC', id: 'txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c', onMessage: (txn) => { console.log(`Status: ${txn.status}`); }, onSuccess: (txn) => { console.log('Transaction completed:', txn.stellar_transaction_id); stop(); }, onError: (err) => { console.error('Transaction failed:', err); stop(); }, }); // Call refresh() to force an immediate poll // Call stop() to stop watching ``` ## on\_change\_callback [#on_change_callback] When you provide an `on_change_callback` URL during deposit or withdrawal initiation, the anchor sends POST requests whenever the transaction status changes: ```json { "transaction": { "id": "txn_8f3a4b2c-1d5e-6f7a-8b9c-0d1e2f3a4b5c", "status": "pending_anchor", "amount_in": "100.00", "amount_out": "6.50" } } ``` ### Callback requirements [#callback-requirements] * Must be a publicly accessible HTTPS URL * Must respond with `200 OK` within 5 seconds * Callbacks are best-effort — not guaranteed delivery * Always verify state by polling after receiving a callback Do not use callbacks as your sole transaction monitoring mechanism. They supplement polling but may be delayed, retried, or missed entirely. ## The more\_info page [#the-more_info-page] Every transaction includes a `more_info_url` — a web page hosted by the anchor showing full transaction details. This is useful for: * Customer-facing transaction receipts * Support and dispute resolution * Embedding in wallet UIs via iframe or link ``` https://anchor.seevcash.com/sep6/transaction/more_info?id=txn_8f3a4b2c ``` The page displays: * Transaction type, status, and timestamps * Amount in/out with exchange rate * Fee breakdown * Stellar transaction link (if completed) * MoMo transaction reference (if applicable) ## Transaction response fields [#transaction-response-fields] | Field | Type | Description | | ------------------------- | ------ | --------------------------------------------------- | | `id` | string | Unique transaction identifier | | `kind` | string | `deposit` or `withdrawal` | | `status` | string | Current status (see lifecycle above) | | `status_eta` | number | Estimated seconds until next status change | | `amount_in` | string | Amount received by the anchor | | `amount_in_asset` | string | Asset identifier for amount\_in | | `amount_out` | string | Amount sent to the customer | | `amount_out_asset` | string | Asset identifier for amount\_out | | `amount_fee` | string | Fee charged | | `amount_fee_asset` | string | Asset the fee is denominated in | | `started_at` | string | ISO 8601 timestamp when transaction started | | `completed_at` | string | ISO 8601 timestamp when completed (null if pending) | | `stellar_transaction_id` | string | Stellar network transaction hash | | `external_transaction_id` | string | External reference (MoMo transaction ID) | | `more_info_url` | string | URL to the transaction detail page | | `message` | string | Human-readable status message or error detail | # SEP-6: Withdraw (USDC → GHS) (/docs/anchor/withdraw) A withdrawal converts USDC on the Stellar network to GHS (Ghana Cedis) paid out to a Mobile Money wallet. The customer sends USDC to the anchor's Stellar address and receives GHS in their mobile wallet. ## Flow overview [#flow-overview] ``` Client Anchor Stellar MoMo Gateway │ │ │ │ │ GET /sep6/withdraw │ │ │ │────────────────────────►│ │ │ │ { account_id, memo } │ │ │ │◄────────────────────────│ │ │ │ │ │ │ │ Send USDC + memo │ │ │ │────────────────────────────────────────────────►►│ │ │ │ │ │ │ │ PaymentMonitor detects │ │ │ │◄────────────────────────│ │ │ │ │ │ │ │ Disburse GHS │ │ │ │────────────────────────────────────────►►│ │ │ │ │ │ on_change_callback │ │ │ │◄────────────────────────│ │ │ ``` ## Prerequisites [#prerequisites] 1. Valid SEP-10 JWT token ([Authentication](/docs/anchor/authentication)) 2. KYC status is `ACCEPTED` ([KYC](/docs/anchor/kyc)) 3. USDC balance in your Stellar account ## Step 1: Initiate a withdrawal [#step-1-initiate-a-withdrawal] ```bash curl -X GET "https://anchor.seevcash.com/sep6/withdraw?\ asset_code=USDC&\ amount=6.5&\ account=GCEXAMPLE4KEYPAIR7HERE2REPLACE5WITH5YOUR5ACTUAL5STELLAR5KEY&\ on_change_callback=https://yourapp.com/webhooks/anchor" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); // Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Initiate withdrawal const sep6 = anchor.sep6(); const withdrawal = await sep6.withdraw({ authToken, params: { asset_code: 'USDC', account: accountKp.publicKey, amount: '6.5', funding_method: 'mobile', }, }); console.log("Withdrawal ID:", withdrawal.id); console.log("Send USDC to:", withdrawal.account_id); console.log("Memo:", withdrawal.memo); ``` ### Parameters [#parameters] | Parameter | Required | Description | | -------------------- | -------- | ------------------------------------------------------------------------ | | `asset_code` | Yes | Asset to withdraw — `USDC` | | `funding_method` | Yes | Payout rail — `mobile` for Mobile Money (`type` is accepted as an alias) | | `amount` | No | Amount of USDC to send | | `account` | No | Your Stellar public key. Defaults to the SEP-10 JWT's account | | `on_change_callback` | No | URL to receive transaction status updates | | `refund_memo` | No | Memo to use if the withdrawal is refunded | | `refund_memo_type` | No | Type of `refund_memo`: `text`, `id`, or `hash` | | `quote_id` | No | A SEP-38 `quote_id` to lock the rate | `funding_method` (or its alias `type`) is required — the anchor returns `400 funding_method is required` without it. Unlike deposit, `account` is optional on withdraw and falls back to the authenticated account. **Response:** ```json { "id": "txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "account_id": "GANCHOR5RECEIVING5ADDRESS5HERE2REPLACE5WITH5ACTUAL5ANCHOR5KEY", "memo_type": "text", "memo": "SEEV-WDR-9A4B5C", "eta": 300, "min_amount": "1", "max_amount": "10000" } ``` The response includes `account_id` (the anchor's Stellar address) and a `memo`. You **must** include this exact memo when sending USDC — it's how the anchor identifies your transaction. ## Step 2: Send USDC to the anchor [#step-2-send-usdc-to-the-anchor] Send the USDC payment to the anchor's Stellar address with the provided memo. ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; import { Asset, TransactionBuilder, Networks, Horizon, Operation, Memo, } from '@stellar/stellar-sdk'; const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const server = new Horizon.Server('https://horizon-testnet.stellar.org'); async function sendUsdcToAnchor( accountKp: Keypair, anchorAddress: string, memo: string, amount: string ) { const account = await server.loadAccount(accountKp.publicKey); const usdc = new Asset('USDC', USDC_ISSUER); const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET, }) .addOperation( Operation.payment({ destination: anchorAddress, asset: usdc, amount, // e.g. "6.5" }) ) .addMemo(Memo.text(memo)) .setTimeout(30) .build(); tx.sign(accountKp); const result = await server.submitTransaction(tx); console.log("Payment submitted:", result.hash); return result; } ``` ## Step 3: PaymentMonitor detects payment [#step-3-paymentmonitor-detects-payment] Once the anchor's `PaymentMonitor` service detects the incoming USDC payment with the matching memo, it: 1. Validates the payment amount and memo 2. Looks up the associated withdrawal transaction 3. Calculates the GHS equivalent at the current rate 4. Initiates the Mobile Money disbursement This happens automatically — no additional API calls are needed from your side. ## Step 4: Auto payout to Mobile Money [#step-4-auto-payout-to-mobile-money] The anchor disburses GHS to the mobile number registered in the customer's KYC data. The payout is automatic once the USDC payment is confirmed. **Payout timing:** * MTN Mobile Money: 1–3 minutes * Vodafone Cash: 1–3 minutes * AirtelTigo Money: 1–5 minutes ## Step 5: Payout confirmation (server-side, not for wallets) [#step-5-payout-confirmation-server-side-not-for-wallets] For the mobile-money corridor the anchor completes the payout automatically — a wallet integration does **not** call anything here. Completion is driven internally: once the off-chain GHS payout is delivered, the transaction moves `pending_external → completed` and your `on_change_callback` fires. There is a `POST /sep6/confirm-withdrawal` endpoint, but it is a **server-to-server** callback for the off-chain payment provider — **not** a wallet-facing call. It is **not** authenticated with the SEP-10 JWT; it requires a shared-secret `x-server-secret` header and must never be exposed to end users. Wallets should rely on polling / `on_change_callback` for completion instead. The server-only contract, for reference (the payment provider calls this, not your wallet): ```bash curl -X POST "https://anchor.seevcash.com/sep6/confirm-withdrawal" \ -H "x-server-secret: " \ -H "Content-Type: application/json" \ -d '{ "transaction_id": "txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "external_transaction_id": "MOMO-REF-12345", "amount_out": "100.00" }' ``` The call only succeeds when the transaction is in status `pending_external`; it then marks the transaction `completed`. **Response (200):** ```json { "transaction_id": "txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "status": "completed", "external_transaction_id": "MOMO-REF-12345", "completed_at": "2026-08-05T01:18:45Z" } ``` ## Poll transaction status [#poll-transaction-status] ```bash curl -X GET "https://anchor.seevcash.com/sep6/transaction?id=txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d" \ -H "Authorization: Bearer " ``` ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; const wallet = Wallet.TestNet(); const anchor = wallet.anchor({ homeDomain: 'anchor.seevcash.com' }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Use the watcher for real-time withdrawal tracking const sep6 = anchor.sep6(); const watcher = sep6.watcher(); const { stop, refresh } = watcher.watchOneTransaction({ authToken, assetCode: 'USDC', id: 'txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d', onMessage: (txn) => console.log(`Status: ${txn.status}`), onSuccess: (txn) => { console.log(`Withdrawal complete! ${txn.amount_out} GHS sent to MoMo`); stop(); }, onError: (err) => { console.error('Withdrawal error:', err); stop(); }, }); // Or get a single transaction directly const { transaction } = await sep6.getTransactionBy({ authToken, id: 'txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d', }); console.log(`Status: ${transaction.status}`); ``` **Response (completed):** ```json { "transaction": { "id": "txn_9a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "kind": "withdrawal", "status": "completed", "amount_in": "6.50", "amount_in_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "amount_out": "100.00", "amount_out_asset": "iso4217:GHS", "amount_fee": "0.10", "amount_fee_asset": "stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "started_at": "2026-08-05T01:15:00Z", "completed_at": "2026-08-05T01:18:45Z", "stellar_transaction_id": "def789abc012...", "more_info_url": "https://anchor.seevcash.com/sep6/transaction/more_info?id=txn_9a4b5c6d" } } ``` ## Withdrawal status flow [#withdrawal-status-flow] ``` pending_user_transfer_start → Waiting for you to send USDC to anchor pending_anchor → Anchor received USDC, processing GHS payout pending_external → GHS payout submitted to MoMo network completed → GHS delivered to customer's mobile wallet error → Something went wrong (check message) ``` ## Full TypeScript example [#full-typescript-example] ```typescript import { Wallet, Keypair } from '@stellar/typescript-wallet-sdk'; import { Asset, TransactionBuilder, Networks, Horizon, Operation, Memo, } from '@stellar/stellar-sdk'; const wallet = Wallet.TestNet(); const ANCHOR_HOME_DOMAIN = 'anchor.seevcash.com'; const anchor = wallet.anchor({ homeDomain: ANCHOR_HOME_DOMAIN }); const accountKp = Keypair.fromSecret('SCZANGBA5YHTNYVVV3C7CAZMCLXPJLNS2YFGXDNASLFTGBPFMRKCY6ML'); const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const server = new Horizon.Server('https://horizon-testnet.stellar.org'); // Full withdrawal flow async function withdraw() { // Step 1: Authenticate const sep10 = await anchor.sep10(); const authToken = await sep10.authenticate({ accountKp }); // Step 2: Initiate withdrawal const sep6 = anchor.sep6(); const withdrawal = await sep6.withdraw({ authToken, params: { asset_code: 'USDC', account: accountKp.publicKey, amount: '6.5', funding_method: 'mobile', }, }); console.log("Withdrawal initiated:", withdrawal.id); console.log("Send USDC to:", withdrawal.account_id); console.log("With memo:", withdrawal.memo); // Step 3: Send USDC to anchor const account = await server.loadAccount(accountKp.publicKey); const usdc = new Asset('USDC', USDC_ISSUER); const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET, }) .addOperation( Operation.payment({ destination: withdrawal.account_id, asset: usdc, amount: '6.5', }) ) .addMemo(Memo.text(withdrawal.memo)) .setTimeout(30) .build(); tx.sign(accountKp); await server.submitTransaction(tx); // Step 4: Watch for completion (GHS payout) const watcher = sep6.watcher(); const { stop } = watcher.watchOneTransaction({ authToken, assetCode: 'USDC', id: withdrawal.id, onMessage: (txn) => console.log(`Status: ${txn.status}`), onSuccess: (txn) => { console.log(`Withdrawal complete! ${txn.amount_out} GHS sent to MoMo`); stop(); }, onError: (err) => { console.error('Withdrawal error:', err); stop(); }, }); } await withdraw(); ``` Always include the exact `memo` returned by the withdraw endpoint. Payments without the correct memo cannot be automatically matched and will require manual intervention. ## Error responses [#error-responses] | Status | Error | Meaning | | ------ | ----------------------- | ------------------------------- | | 400 | `invalid_amount` | Amount outside min/max range | | 400 | `invalid_asset_code` | Asset not supported | | 403 | `kyc_required` | KYC not yet approved | | 404 | `transaction_not_found` | Transaction ID does not exist | | 500 | `internal_error` | Anchor-side error — retry later | # Developer API Keys (/docs/developer/api-keys) API keys authorize every request to a Seev API product. Keys are scoped to one product and one environment, so a sandbox Checkout API key cannot sign a production request and cannot be used against the Exchange Widget API. This page is the reference for authentication; the pages for [Checkout](/docs/payments/checkout) and [Webhooks](/docs/developer/webhooks) assume you already hold a key. Before you can create a key, open the [Developer Dashboard](/docs/developer) and accept the developer terms for the active organization. Accepting the terms enables developer access, and it does not create a key for you. This page covers **Checkout / SeevPlus** API keys, used against `https://api.seevplus.com`. The **Stellar anchor** and **Exchange Widget (SEP-24)** flow uses a **separate** key system — merchant keys in the `sv_public_...` / `sv_secret_...` format, created at `widget-backend.seevcash.com/api/v1/merchant/apikeys`. The two are different services on different hosts and are not interchangeable: an anchor/SEP-24 request signed with a Checkout key is rejected. For that flow, see [SEP-24: Interactive Deposit & Withdrawal](/docs/anchor/sep24). ## Authenticate a request [#authenticate-a-request] Send the key from your server in either the `Authorization` header or the `X-API-Key` header. Seev reads `X-API-Key` first and falls back to a `Bearer` token in `Authorization`, so sending both is redundant. ```bash 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" }, "amount": 10000, "currency": "GHS", "redirect_url": "https://yourapp.com/payment/callback" }' ``` `amount` is expressed in the currency's smallest unit, so `10000` means GHS 100.00. The same convention applies to item prices. See [Checkout API](/docs/payments/checkout) for the full request body. | Header | Type | Required | Description | | ----------------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `Authorization` | string | Conditional | `Bearer `. Required unless `X-API-Key` is sent. | | `X-API-Key` | string | Conditional | The secret key with no scheme prefix. Takes precedence over `Authorization`. | | `Idempotency-Key` | string | No | Forwarded to the payment gateway. Maximum 128 characters. Without it, every request creates a new payment. See [Idempotency](/docs/idempotency). | | `Content-Type` | string | Yes | `application/json`. | Keep secret keys on your server. A key in browser code, a mobile app bundle, a client-side build, a screenshot, or a log line is a key you have to rotate. ## Choose a product for the key [#choose-a-product-for-the-key] Each Seev API product is backed by a separate service, so you pick the product at creation time and the key is valid only for that service. | Product | Type | Required | Description | | ------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | Checkout API | product | Yes | Checkout session creation and payment collection requests. The only product accepted by `POST /api/v1/developer/payments`. | | Exchange Widget API | product | Yes | Embedded exchange widget requests. | | KYC | product | Yes | Verification requests. Not yet available. | You can hold several keys for the same product, which is how you give each deployed application its own credential and rotate one without disturbing the others. ## Generate a key [#generate-a-key] 1. Open **Seev API -> API Keys** and confirm the environment selector shows the environment you want. 2. Click **Generate Key**. 3. Select the product. 4. Enter a name that identifies where the key will run, such as `Tally Mobile App` or `Merchant Portal`. 5. Optionally enable IP whitelisting and enter the public IP addresses your server sends from. 6. Click **Create Key**. A new key returns a public key and a secret key. The full secret is shown once, on the confirmation screen, and the API Keys table afterwards shows only the public key and a masked prefix of the secret. Copy the secret into a secrets manager before you close that screen. If you lose it, Seev cannot show it again, and your only path back to a working credential is to rotate the key and update your application with the new secret. Store the secret in an environment variable or a secrets manager such as AWS Secrets Manager, HashiCorp Vault, Doppler, or your host's own secret store, and load it at runtime. ```bash # .env (local development only, never committed) SEEV_CHECKOUT_API_KEY=your_sandbox_or_production_key ``` ```bash # .env.example (safe to commit) SEEV_CHECKOUT_API_KEY= ``` ## Restrict a key by IP address [#restrict-a-key-by-ip-address] IP whitelisting compares the public IP that the request arrives from against the list stored on the key, and rejects anything else. The comparison is an exact string match against the first address in the forwarded chain, so ranges and CIDR blocks are not accepted. Enable it for server-side integrations with stable outbound addresses. Leave it off where your egress IP rotates, unless you can update the list before it changes, because a stale list fails every request on that key. A blocked request returns `403`: ```json { "error": "request IP is not allowed for this API key", "message": "request IP is not allowed for this API key" } ``` Add the current egress address to the key's whitelist, or disable whitelisting on the key, then retry. ## Move a key from sandbox to production [#move-a-key-from-sandbox-to-production] Keys belong to one environment and cannot cross over. | Environment | Type | Required | Description | | ------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------ | | `sandbox` | string | Yes | Test requests. No live money moves. Available before organization verification is approved. | | `production` | string | Yes | Live requests against real customer funds. Requires approved organization verification and an active organization. | Switching the environment selector in the dashboard changes which keys the API Keys tab lists. Going live means generating a separate production key and deploying it, not promoting the sandbox key. ## Rotate a key [#rotate-a-key] Rotate when a secret may have leaked, when someone with access leaves, or on the schedule your credential policy sets. Rotation revokes the previous key record and issues a replacement whose secret is shown once, the same as at creation. Rotation is immediate, so sequence the rollout to avoid a window where the deployed application holds a revoked secret: 1. Rotate the key in the dashboard and copy the new secret. 2. Write the new secret into your application's secret store. 3. Deploy, then confirm a sandbox request or a low-value controlled production request succeeds. 4. Remove the old value from environment files, CI variables, and local copies. Rotation and deletion take effect immediately and cannot be undone. Any deployed application still presenting the old secret starts receiving `401` on its next request. ## Delete a key [#delete-a-key] Deleting removes the key from its environment and stops every request that presents it. Confirm no running deployment depends on it first. Giving each application its own named key is what makes deletion safe to do without an outage. ## Fix a rejected request [#fix-a-rejected-request] Seev returns errors as a JSON object carrying the same string in both fields: ```json { "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" } ``` | Status | `error` message | Cause | What to do | | ------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `401` | `API key is required` | Neither `X-API-Key` nor a `Bearer` token reached the server. | Send the key. Check that a proxy is not stripping the header. | | `401` | `invalid API key; existing keys must be rotated once before using this endpoint` | The secret does not match an active Checkout API key, or the key predates the current storage format. | Confirm the value has no trailing whitespace and belongs to the Checkout product and this environment. A key created before the current format works only after one rotation, so rotate it once. | | `401` | `API key is not linked to an organization` | The key record has no organization attached. | Regenerate the key from the organization that should own it. | | `403` | `request IP is not allowed for this API key` | IP whitelisting is on and the request's public IP is not listed. | Add the egress IP or disable whitelisting on that key. | | `400` | `organization is not active` | The owning organization is suspended or not in an active state. | Check organization status and verification before retrying. | | `400` | `Idempotency-Key must not exceed 128 characters` | The header is longer than 128 characters. | Shorten the key. An internal order ID plus an attempt counter is enough. | Deleted and rotated-away keys behave like unknown keys, so they return the `invalid API key` message rather than a distinct revoked-key error. ## Check before you go live [#check-before-you-go-live] * Generate a fresh production key instead of reusing sandbox configuration. * Give every deployed application its own named key. * Add IP restrictions only where the egress addresses are stable. * Verify payments on your server before fulfilment. A browser redirect is not proof of payment. * Configure a signed webhook endpoint for asynchronous updates. * Write down the rotation procedure before you need it. ## Related docs [#related-docs] * [Checkout API](/docs/payments/checkout) * [Developer Webhooks](/docs/developer/webhooks) * [Developer Transactions](/docs/developer/transactions) * [Idempotency](/docs/idempotency) * [Errors and codes](/docs/errors) # API Customers (/docs/developer/customers) API Customers is a read-only view built by grouping the transactions in [Developer Transactions](/docs/developer/transactions). Nothing is stored separately, so there is no customer record here to create, edit, or delete. To manage the customers you save for invoices and payment links, use the [Dashboard Customers guide](/docs/organizations/customers). ## Read the rollup [#read-the-rollup] | Field | Type | Required | Description | | ------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | No | Customer name taken from the transaction metadata. Empty when your integration sent none. | | `email` | string | No | Email from the transaction metadata, lowercased when used as the grouping key. | | `phone` | string | No | Phone number from the transaction metadata. | | `customerId` | string | No | Your own customer identifier, read from the `customerId` or `customer_id` key of the `meta` object you sent at checkout. | | `recipientId` | string | No | Your own recipient identifier, read from `recipientId` or `recipient_id` in the same `meta` object. | | `transactionCount` | number | Yes | Count of grouped transactions, in every status. | | `totalAmount` | number | Yes | Sum of the grouped transaction amounts in the currency's **major** unit, since transaction amounts are stored as the smallest-unit Checkout API value divided by 100. | | `currency` | string | No | Currency of the most recently created transaction in the group, not of the total. | | `lastStatus` | string | No | Status of the most recently created transaction: `pending`, `completed`, `failed`, or `cancelled`. | | `lastReference` | string | No | Payment reference of that same transaction. Open it in Developer Transactions for the full detail. | | `lastTransactionAt` | string | Yes | Creation time of the most recent transaction. Rows are sorted by this, newest first. | Sandbox and production rollups are separate and are marked as such. Changing the developer environment reloads the list, so check the environment badge before you quote a total anywhere. ## Send an identifier so the grouping works [#send-an-identifier-so-the-grouping-works] Transactions are grouped by the first of these that your integration provided, in order: email, then `customerId`, then `recipientId`. If none is present, Seev falls back to the transaction's own gateway session reference or payment reference, which is unique per payment. That means every anonymous payment becomes its own single-transaction row. To keep one customer as one row, send a stable identifier in the `meta` object when you create the checkout session: ```json { "type": "checkout", "recipient": { "name": "Kwame Asante", "email": "kwame@example.com" }, "amount": 10000, "currency": "GHS", "redirect_url": "https://yourapp.com/payment/callback", "meta": { "orderId": "order_123", "customerId": "cus_4821" } } ``` `amount` is in the currency's smallest unit, so `10000` means GHS 100.00. The full request body is documented in [Checkout API](/docs/payments/checkout). ## Interpret the totals carefully [#interpret-the-totals-carefully] The rollup is a convenience view built from whatever your integration sent. Four behaviours will surprise you if you treat it as a ledger. | Behaviour | Cause | What to do | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | The same person appears several times | Different payments carried different identifiers, or some carried none and fell back to a per-payment reference. | Send the same `customerId` or the same email on every checkout for that customer. Existing rows are not merged retroactively. | | `totalAmount` is larger than what you were paid | Every transaction is counted regardless of status, so pending, failed, and cancelled amounts are in the sum. | Use Developer Transactions filtered to `completed` for anything financial. Treat this total as activity volume. | | `totalAmount` mixes currencies | Amounts are summed across the group while `currency` reports only the most recent transaction's currency. | For a customer who paid in more than one currency, read the per-transaction rows instead. | | `lastStatus` looks stale | The row tracks the most recently **created** transaction, not the most recently updated one. A newer session that is still pending outranks an older one that completed a moment ago. | Open `lastReference` in Developer Transactions and read the status there. | ## Fill in missing customer details [#fill-in-missing-customer-details] A row with no name, email, or phone means the checkout session was created without them. Seev shows the strongest identifier it has rather than an empty row, which keeps the record inspectable. Sandbox sessions often lack contact details, and that is expected. If production rows are missing details you thought you were sending, check the `recipient` object and the `meta` keys in your create-session request against the key names listed in the table above, since a key spelled differently is read as absent. Do not guess an identity into your own records from a rollup row. ## Related docs [#related-docs] * [Developer Transactions](/docs/developer/transactions) * [Checkout API](/docs/payments/checkout) * [Dashboard Customers](/docs/organizations/customers) # Developer Dashboard (/docs/developer) The Developer Dashboard is where you accept the developer terms, switch between sandbox and production, generate keys, inspect API transactions and customers, and configure webhooks. Each of those has its own page; this one covers the setup that comes first and the order to do things in. ## Decide whether you need API access [#decide-whether-you-need-api-access] Use the Developer Dashboard only when your own app, website, or backend creates payments programmatically. Dashboard invoices, payment links, storefronts, and POS need no API key and no developer access at all. If nobody on your team writes code, [Get Paid with Seev Plus](/docs/payments) will get you further than this section. ## Enable developer access [#enable-developer-access] 1. Open **Seev API** in the Seev Dashboard. 2. Review and accept the API terms and conditions for the active organization. 3. Open the **API Keys** tab and generate a named sandbox key for the product you are integrating. Accepting the terms enables developer access without creating any credential, so nothing works until you generate a key. The full procedure, including the view-once secret, IP restrictions, and rotation, is in [API Keys](/docs/developer/api-keys). Sandbox is available before organization verification is approved. Production requires an approved organization, so start verification early if you have a launch date. ## Pick the product your key belongs to [#pick-the-product-your-key-belongs-to] Each product is a separate Seev service, and a key issued for one is rejected by another. | Product | Status | Description | | ------------------- | ----------- | -------------------------------------------------------------------------- | | Checkout API | Available | Create checkout sessions and collect payments from your own app or server. | | Exchange Widget API | Available | Embed Seev exchange experiences in your product. | | KYC | Coming soon | Verify customers or businesses through the API. | Routes beginning with `/api/organizations/{orgId}/...` back the authenticated Seev Plus screens and are not merchant integration routes. Build against the routes in the [Checkout API guide](/docs/payments/checkout). ## Work in the right environment [#work-in-the-right-environment] There are two environments, and the key you send chooses between them. There is no environment setting anywhere else, and no switch to flip. | Environment | What it is | Who can use it | | ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------- | | Sandbox | Test requests. No real money moves. | Any organization that has accepted the developer terms, before or after verification. | | Production | Live requests against real customer funds. | Organizations whose verification is approved. | Every key belongs to exactly one environment and one product. Sandbox and production keep separate keys, transactions, customers and webhook endpoints. A sandbox transaction is never evidence that a customer paid, and a sandbox key presented to production is rejected as an unknown key. If developer access has been turned off for your organization, every key stops working in both environments and the dashboard says so. Write to support to have it turned back on. ## Integrate in this order [#integrate-in-this-order] 1. Accept the developer terms. 2. Generate one clearly named Checkout API sandbox key and store the secret. 3. Create a sandbox checkout session from your backend. 4. Test a success, a failure, and a repeated submission of the same order. 5. Verify the payment on your server before you fulfil anything. 6. Create a sandbox webhook endpoint, store its signing secret, and confirm a signed delivery reaches your handler. 7. Complete organization verification. 8. Generate a separate production key and run one controlled live payment before opening the flow to customers. ## Confirm the integration is production ready [#confirm-the-integration-is-production-ready] Before real customers reach it, check that your integration: * Creates checkout sessions from a trusted server, never from browser code. * Stores your own order alongside the Seev reference so the two can be reconciled. * Sends a stable `Idempotency-Key` when it retries a create request. See [Idempotency](/docs/idempotency). * Redirects customers only to the checkout URL Seev returned. * Verifies payment results server-side before fulfilment, treating the browser redirect as a hint rather than a result. * Deduplicates webhook events on the payment reference, because event IDs change on replay. See [Webhooks](/docs/developer/webhooks). * Shows customers a useful pending state and a useful failed state. * Keeps sandbox and production credentials in separate secret stores. ## Unblock a dashboard that will not let you proceed [#unblock-a-dashboard-that-will-not-let-you-proceed] | Message or symptom | Cause | What to do | | -------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Developer terms must be accepted | The active organization has not accepted the API terms. | Open Seev API and complete the terms step. Terms are per organization, so accepting them once does not cover the others. | | Production requires verification | Organization verification is not approved. | Finish [verification](/docs/organizations/verification), or switch back to sandbox and keep building. | | Developer account disabled | Developer access has been turned off for this organization. | Every key stops working in both environments until it is turned back on. Write to support. | | No keys are listed | You are looking at a different environment or a different organization from the one that owns the keys. | Check the environment selector and the organization selector, then reload the tab. | | Requests return `401` | The key does not match the product, the environment, or the current key format. | Work through the error table in [API Keys](/docs/developer/api-keys). | ## Find your money after a successful payment [#find-your-money-after-a-successful-payment] Funds from successful API transactions are deposited into your main Seev account, where you can withdraw them or move them to another wallet. The Developer Transactions tab shows only API-created activity, so invoices, payment links, and storefront orders will not appear there. See [Developer Transactions](/docs/developer/transactions). ## Related docs [#related-docs] * [Developer API Keys](/docs/developer/api-keys) * [Developer Transactions](/docs/developer/transactions) * [API Customers](/docs/developer/customers) * [Developer Webhooks](/docs/developer/webhooks) * [Checkout API](/docs/payments/checkout) * [Errors and codes](/docs/errors) # Developer Transactions (/docs/developer/transactions) The Developer Transactions tab lists transactions created through Seev API products, newest first. It is a focused view for API activity rather than a replacement for your main account activity, and nothing you create in the Dashboard, such as an invoice, payment link, or storefront order, appears here. Funds from successful API transactions land in your main Seev account, where you withdraw them or move them to another wallet. Settlement is not something you drive from this tab. ## Read the transaction list [#read-the-transaction-list] | Field | Type | Required | Description | | ------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `reference` | string | Yes | The Seev payment reference, for example `PAY-20260718-f23159df-...`. Match this against the reference your own order stored. | | `gatewaySessionRef` | string | Yes | The gateway session reference. For Checkout API payments it is the same value as `reference`. | | `product` | string | Yes | The API product that created the transaction. `checkout` for Checkout API payments. | | `type` | string | Yes | The transaction type, `payment` for a checkout collection. | | `amount` | number | Yes | The amount in the currency's **major** unit. The Checkout API takes amounts in the smallest unit, and this field is that value divided by 100, so a session created with `amount: 10000` shows here as `100`, meaning 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. | | `currency` | string | Yes | Uppercase currency code, for example `GHS`. | | `status` | string | Yes | `pending`, `completed`, `failed`, or `cancelled`. | | `env` | string | Yes | `sandbox` or `production`. | | `checkoutUrl` | string | No | The hosted checkout URL issued for the session. | | `expiresAt` | string | No | When the checkout session stops accepting payment. | | `createdAt` | string | Yes | When the session was created. | | `updatedAt` | string | Yes | When the status last changed. Use this to order two events for the same reference. | Amounts here are in major units while the Checkout API request and response are in the smallest unit. Convert before comparing a row against your stored order total. Selecting a row opens its detail panel, which adds the customer name, email, and phone reported by the gateway, your own `meta` object as sent at creation, and the creation and update timestamps. ## Filter the list [#filter-the-list] The environment selector in the developer section decides which set you are looking at. Sandbox and production data never mix, and a sandbox transaction is never evidence that a customer paid. The period filter is a rolling window measured back from now, not a calendar bucket: | Value | Type | Required | Description | | ------- | ------ | -------- | ------------------------------------------------------ | | `all` | string | No | Every transaction. Used when the period is left empty. | | `day` | string | No | The last 24 hours. | | `week` | string | No | The last 7 days. | | `month` | string | No | The last calendar month back from today. | | `year` | string | No | The last calendar year back from today. | Anything else is rejected: ```json { "error": "invalid period. must be 'all', 'day', 'week', 'month', or 'year'", "message": "invalid period. must be 'all', 'day', 'week', 'month', or 'year'" } ``` The status filter is an exact match on the stored status, so `completed`, `pending`, `failed`, and `cancelled` each return only their own rows and an empty status returns all of them. The overview graph and the failed-transactions section are the same query with these filters applied. ## Investigate a payment a customer disputes [#investigate-a-payment-a-customer-disputes] 1. Confirm which environment you are viewing. A production complaint against a sandbox row explains itself. 2. Search for the reference your own order recorded. If your order has no reference, the create request never returned, and there may be no transaction to find. 3. Open the row and compare amount, currency, product, and timestamps against your order, remembering the major-unit difference. 4. Read the current status. 5. Line the reference up against your [webhook delivery logs](/docs/developer/webhooks) and your own server logs to see which side lost the message. | Status | Cause | What to do | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completed` | The gateway confirmed payment and SeevPlus recorded it. | Fulfil only after server-side verification or a verified webhook. The dashboard row is for humans, not a fulfilment trigger. | | `pending` | The session was created and no final result has arrived. The customer may still be paying, or may have abandoned checkout. | Keep the order unpaid and check again. No webhook is emitted while a transaction stays pending, so waiting for a message that never comes is a real failure mode. | | `failed` | The gateway declined or the payment failed. A `payment.failed` event was emitted if an endpoint subscribes to it. | Offer the customer a retry path and keep the failed reference for the investigation. | | `cancelled` | The payment was cancelled. This also emits `payment.failed`, since there is no separate cancellation event. | Treat it as unpaid. Distinguish it from a decline using the stored status rather than the event name. | | No row at all | The create request failed before SeevPlus recorded anything, or the recording step errored after the gateway accepted the session. | Check your server logs for the create response. A gateway session can exist without a row here, in which case verify by reference against the Checkout API. | Do not ask a customer to pay again while the first transaction is still `pending`. Wait for a final status, or confirm the session has expired, before creating a second one. ## Read the API customer rollup [#read-the-api-customer-rollup] The same transactions are grouped into read-only customer summaries, separated by environment. That view is documented in [API Customers](/docs/developer/customers). ## Related docs [#related-docs] * [Developer Dashboard](/docs/developer) * [Developer Webhooks](/docs/developer/webhooks) * [API Customers](/docs/developer/customers) * [Checkout API](/docs/payments/checkout) * [Errors and codes](/docs/errors) # Developer Webhooks (/docs/developer/webhooks) 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](/docs/developer/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 [#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: ```json { "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 [#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 [#read-the-payload] Every delivery is a `POST` with a JSON body in this envelope: ```json { "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 [#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: ```text "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. ```typescript 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 [#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 [#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: ```typescript 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 [#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 ` 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](/docs/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. | ## Related docs [#related-docs] * [Developer API Keys](/docs/developer/api-keys) * [Developer Transactions](/docs/developer/transactions) * [Checkout API](/docs/payments/checkout) * [Idempotency](/docs/idempotency) # Payment Methods (/docs/payments/channels) Mobile money is the only method that can take a live payment today. Open a checkout link yourself to see what a given payer will be offered, since the methods on the page vary with the checkout type, the currency and the organization's eligibility. ## Accept a mobile money payment [#accept-a-mobile-money-payment] On a supported Ghana cedi checkout, the customer picks mobile money and pays from a wallet on one of the networks shown. What happens on their side: 1. They open the payment link and choose mobile money. 2. They enter the phone number registered with their network. 3. They select the network. 4. They continue, and approve the prompt that arrives on the phone. 5. Seev waits for the provider's final result and updates the payment status. Tell them to keep the phone nearby, unlocked and on the network, and not to restart the payment while the first attempt is still pending. A second attempt started before the first resolves is a second charge, not a replacement for the first. ## Accept a USSD payment from your backend [#accept-a-ussd-payment-from-your-backend] USSD is a server-side flow available through the [Checkout API](/docs/payments/checkout), not a button on a hosted page. Send `channels: ["ussd"]` as the only channel, along with the customer's `phone` and a `network` of `MTN`, `VODAFONE` or `AIRTEL`. Seev returns a USSD intent rather than a `checkout_url`, so nothing redirects the customer back to you and your integration has to poll the session until it reaches a final status. The full request shape and the intent response are on the [Checkout API](/docs/payments/checkout) page. ### Who can use USSD [#who-can-use-ussd] USSD is for **verified businesses**. A prompt lands on a phone whose owner never opened anything of yours, so whoever sends one has to be a business Seev holds verified documents for. Individual accounts and businesses that have not completed verification get a `403` on live keys and can use the hosted checkout channels, which the customer opens themselves. Sandbox keys are not restricted, so you can build before verification completes. ### How often a number can be prompted [#how-often-a-number-can-be-prompted] Each phone number can receive at most three prompts from you in fifteen minutes, and none while one of your prompts is still waiting on it. A request past either limit gets a `429` and no session is created. Poll the session you already have rather than sending another; a customer who has not answered a prompt is rarely helped by a second one. Payment links, invoices and storefront checkout are unaffected by this. They continue to show whatever the hosted checkout page has enabled. ## Check what a method can do today [#check-what-a-method-can-do-today] | Method | Collect from customers | Notes | | -------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Mobile money | Yes | MTN, Telecel and AT wallets on a Ghana cedi checkout. | | USSD | Yes | Server-driven through the Checkout API, not a button on a hosted page. | | Card | No | Card issuing is live, so your team can spend from a funded virtual card. Collection is not. | | Direct bank transfer | No | Appears in previews as upcoming. | | USDC | Yes | Through the Checkout API with `channels: ["crypto"]`, on a live key. Needs a USDC account, which is opened the first time you ask for it or from **Accounts** in the dashboard. Not available on split payments. | Dashboards and previews show the unavailable methods as upcoming, which is easy to read as available. Quote a customer only what you saw selectable on the checkout you opened. ## Resolve a mobile money prompt that never arrived [#resolve-a-mobile-money-prompt-that-never-arrived] The payment sits at `pending` and the phone stays quiet. Ask them to confirm the number and network they entered, check signal and account standing, and wait briefly in case the provider is slow. Refresh the payment status before anyone retries. If the attempt has moved to `failed`, the customer can safely start a new one. If it is still `pending`, do not create another payment request. A pending attempt can still complete, and two live attempts means the customer can be charged twice. ## Handle a customer who says they paid while Seev shows pending [#handle-a-customer-who-says-they-paid-while-seev-shows-pending] Do not fulfil on the strength of a phone screenshot. Refresh the Seev record and wait for a completed status, then match the Seev payment reference against the source invoice, payment link, order or API transaction before you release anything. A provider confirmation on the customer's handset can precede the final result Seev receives. If the status has not resolved after a reasonable wait, quote the payment reference to support rather than creating a replacement request. ## Test both outcomes in sandbox [#test-both-outcomes-in-sandbox] Sandbox checkout moves no money and offers explicit success and declined actions, so you can exercise a failed payment as deliberately as a successful one. A sandbox result is never evidence of a live payment. ## Checklist before you share a checkout [#checklist-before-you-share-a-checkout] * Confirm the checkout currency is the one you intend to charge in. * Open the checkout once and note which methods are actually selectable. * Tell the customer to use the number registered with their mobile money network. * For direct USSD, send a matching `phone` and `network`, and do not combine `ussd` with another channel. * Wait for a final status before fulfilling or creating another request. * Keep the payment reference to hand for any investigation. ## Related guides [#related-guides] * [Get Paid with Seev Plus](/docs/payments) * [Verify a payment](/docs/payments/verify) * [Payment troubleshooting](/docs/troubleshooting) # Checkout API (/docs/payments/checkout) POST /api/v1/developer/payments Requires developer access and an API key Use 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](/docs/payments/links), [Invoices](/docs/payments/invoices) or the [Storefront](/docs/payments/storefront) instead. To get access, open the [Developer Dashboard](/docs/developer), accept the developer terms, select Checkout API as a product, and generate a key. The [API Keys guide](/docs/developer/api-keys) walks through that. ## Trace one payment end to end [#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_url` with 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 [#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 `. 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 [#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. ```bash 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" } }' ``` ```javascript 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); ``` ```python 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 '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']; ``` ```go 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. ```json { "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 [#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](/docs/payments/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 [#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](/docs/organizations/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. A live key on an individual or unverified account asked for `channels: ["ussd"]`. Complete business verification in the dashboard, or use the hosted checkout channels. You have sent this phone three prompts in fifteen minutes, or one of yours is still waiting on it. No session was created. Poll the session you already have instead of sending another. 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 [#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](/docs/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 [#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`. USSD is for verified businesses. A live key on any other account gets a `403`. Because a prompt reaches a phone its owner never opened anything on, each number may receive at most three prompts from you in fifteen minutes, and none while one of yours is still pending; see [USSD](/docs/payments/channels#accept-a-ussd-payment-from-your-backend). ```bash 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`: ```json { "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 [#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. ```json { "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](/docs/payments/subaccounts) covers setting one up and what happens to the money once a split payment succeeds. ## Choose the channels you offer [#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`. ### The first payment from a phone number [#the-first-payment-from-a-phone-number] The first time a mobile money number pays your business on the hosted page, Seev sends a six-digit code to that number over WhatsApp and SMS and asks for it before the prompt goes out. It confirms the person at the checkout holds the phone being charged, which is what stops a stranger pushing a payment prompt to someone else's number. Once a number has answered a code for your business it is remembered, so a returning customer never sees the step again. Nothing changes on your side: the step happens inside the hosted page before the charge, and the session reaches `completed` or `failed` exactly as before. The sandbox skips it. ### Before you offer USDC [#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](/docs/payments/channels) covers current availability and what to tell a customer whose payment stalls. ## Verify the payment before you fulfil [#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. ```bash curl -X GET \ "https://api.seevplus.com/api/v1/developer/payments/$SESSION_REF" ``` ```javascript 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); ``` ```python 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 = 300) { throw new RuntimeException($payload['error'] ?? 'verification failed'); } $paid = in_array($payload['data']['status'], ['completed', 'success'], true); ``` ```go 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 [#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 [#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 [#fix-common-integration-problems] Your fulfilment ran off the redirect and the customer closed the tab first. Move fulfilment onto a signed webhook and process it idempotently, and use the redirect only to show the customer a result. You generated a new idempotency key on each attempt. Derive one key from your order ID and reuse it for every attempt of that same order. Complete organization verification, switch the Developer Dashboard to production, and create a separate production key. Sandbox credentials cannot process production activity. Sandbox checkout moves no money and offers explicit success and declined actions so you can exercise both paths. Never treat a sandbox result as evidence of a live payment. Not yet. Official SDKs, browser packages, mobile SDKs, and platform plugins such as WordPress or WooCommerce are not available. Call the REST API from your backend. 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](/docs/payments/verify) covers. Official SDKs, browser and mobile packages, and platform plugins such as WordPress or WooCommerce. Which methods can take a live payment today, and what to tell a customer when one cannot. The delivery timing and ordering your handler has to tolerate before you fulfil. # Get Paid with Seev Plus (/docs/payments) Seev Plus runs your payment and commerce workflows from one business workspace. You can issue invoices and payment links, sell through a storefront or a POS screen, hold balances in wallets, and put payments inside your own product through the Seev API. This page walks the setup in the order it has to happen, then covers what to do once money starts arriving. ## Create your organization [#create-your-organization] An organization is the business workspace that owns everything you create: stores, invoices, customers, wallets, API keys, verification state, and team access. One login can belong to several organizations, and their records never mix. After signing in for the first time, enter a business name, create the organization, then confirm it in the selector at the bottom of the sidebar. Check the active organization before you create a record or move money. A payment link made in the wrong workspace settles into the wrong wallet. See [Organizations](/docs/organizations) for switching and roles. ## Complete organization verification [#complete-organization-verification] You can explore and prepare most of the product before approval. Accepting live payments, using production API access, and some payout actions all wait on approved verification, so start this early rather than on launch day. The dashboard reports whether verification is not started, in draft, ready for review, approved, or rejected. See [verification](/docs/organizations/verification) for what each document has to show and what approval unlocks. ## Choose how to get paid [#choose-how-to-get-paid] Every option below ends at the same hosted checkout, so the payer's experience is the same whichever you choose. What differs is where the request starts and how much structure it carries. Take the least complicated one that fits the sale. The customer needs line items, tax, discount, terms, or a due date. A recipient, an amount, and a short description are all you need. Customers should browse products and build their own cart. A walk-in customer whose products you select on their behalf. Your own app or website creates the payment. Part of each payment belongs to a partner, branch or vendor and should settle to them directly. The dashboard tools need no code. The Checkout API needs developer terms, a product-specific key, server-side code, payment verification, and webhook handling, so choose it when your application has to own the flow rather than because it sounds more capable. Creating an invoice, link, or store is not the same as being able to accept payment. Without approved verification, Seev lets you prepare the item and blocks generation of a live checkout link. ## Find where funds land [#find-where-funds-land] Successful payments settle to the active organization's accounts. Open **Balances** to see the GHS wallet, the stablecoin wallet, transaction history, and any virtual account available to the organization. The currency picker changes which wallet you are looking at. It does not convert one balance into another, and a GHS balance and a USDC balance remain separate funds. Payments made through an invoice, a payment link, a store order, a POS order and the API can appear in different operational tables, but the funds belong to the organization that created the payment in every case. If a payment landed under an organization you did not expect, check which one was active when it was created. ## Set a transaction PIN [#set-a-transaction-pin] Each organization has its own four-digit transaction PIN, and Seev asks you to set it before the first sensitive action such as a withdrawal, a transfer, a card action, or a yield movement. Digits stay hidden while you type. The PIN is yours and not the team's, so do not share it with teammates. See [PIN guidance](/docs/accounts/pin). ## Configure payouts [#configure-payouts] Settled funds go out on a schedule of every business day, weekly, or monthly. Add a payout destination before the first payout can run, because a schedule with no destination has nowhere to send to. Manual payouts are no longer offered. See [payout settings](/docs/settings/payouts). The correct organization is selected. Organization verification is approved. The customer, amount, currency, and description are right. Invoice line items are complete, if you are using an invoice. Store products are active, in stock, and carry an image. The checkout link opens for you before you share it. The completed payment appears in its own record and in the Balances activity feed. ## Know what the customer sees [#know-what-the-customer-sees] The customer opens a Seev-hosted checkout page showing your business, the amount, the purchase details and the payment methods currently available for that payment. After they pay, Seev verifies the result and updates the source invoice, link, order or API transaction. For a POS order, the payer can supply an email for their receipt without you creating a full customer profile first. Available methods are decided per payment rather than per tool, so read [Payment Methods](/docs/payments/channels) before telling a customer how to pay. ## Follow a payment status [#follow-a-payment-status] | Status | What to do | | -------------------- | -------------------------------------------------------------------------------------- | | **Draft** | Finish preparing the request before sharing it. | | **Active / Sent** | The request is ready for the customer. | | **Pending** | Wait for a final confirmation; do not immediately ask the customer to pay again. | | **Paid / Completed** | The payment succeeded. Continue fulfilment and retain the receipt record. | | **Failed** | Read the reason and let the customer retry when appropriate. | | **Expired** | The checkout can no longer accept payment. Create a new request if money is still due. | | **Cancelled** | The merchant intentionally stopped the request. It cannot be paid. | ## Avoid charging a customer twice [#avoid-charging-a-customer-twice] A pending payment can still complete. Creating a replacement while the first attempt is live is how a customer ends up paying twice, and a mobile money charge is not something you can quietly undo. Before you create a replacement checkout, refresh the original record, check its current status, search the reference in transaction activity, and confirm the customer was not already charged. Paid records deliberately remove the copy, QR and checkout-generation actions for the same reason. If those controls are still visible on a record you believe was paid, you are looking at a stale page. Refresh it. Missing organizations, blocked checkout links, balances, products, verification, PINs, and payment statuses. Create and switch between business workspaces, and keep each one's money, records, and access separate. # Invoices (/docs/payments/invoices) An invoice is a payment request with a paper trail: line items, an optional discount and tax, an optional due date, and a downloadable PDF at the end. Each invoice is a one-time request, so a retainer means one invoice per period. The customer expects a document: itemized charges, tax, a discount, a due date, or a PDF receipt. One amount and a note, which is a [payment link](/docs/payments/links). Browsable products, which is a [storefront](/docs/payments/storefront). Approved [verification](/docs/organizations/verification) to generate a live checkout link. Drafts work before that. ## How an invoice moves [#how-an-invoice-moves] An invoice has one status at a time. The status decides what the customer can do and what you can still change. Created but not yet sent. Emailed or shared as a link, awaiting payment. The customer has opened the invoice link. The due date passed with no payment and no cancellation. Payment completed. The receipt record is retained. Cancelled manually. The payment link is deactivated. ## Create an invoice [#create-an-invoice] From the dashboard, go to **Invoices → New Invoice**. An invoice cannot be continued until it has a customer and at least one line item. ## Customer [#customer] Select a saved customer, or add a new one while creating the invoice. Saved customers carry over to payment links, so the same contact details are entered once. Shown on the invoice header and on the checkout page. Where the invoice email and the receipt are sent. Optional, if you have one on file. ## Line items [#line-items] One row per thing you are charging for. Seev calculates the total. At least one, each with a description, a quantity and a unit price. A percentage, deducted from the items subtotal first. A percentage, applied to what is left after the discount rather than to the original subtotal. Unit price is entered in major units, so a GHS 100.00 charge is typed as `100.00`. The smallest-unit convention applies only to the [Checkout API](/docs/payments/checkout). ## Terms [#terms] Drives the Overdue status once it passes with no payment. Message shown to the customer on the invoice. Auto-generated, and editable if you keep your own numbering. ## Review and send [#review-and-send] **Send** emails the invoice. **Copy Link** hands you the same hosted link to share yourself. You can prepare and save an invoice before organization verification is approved, but a live payment link cannot be generated until the active organization is approved. Do not send an unverified invoice and expect the customer to be able to pay it. ## What the customer sees, and what happens when they pay [#what-the-customer-sees-and-what-happens-when-they-pay] The customer opens the invoice link, reviews the items and the total due, and clicks **Pay Now**. That hands them to the Seev-hosted checkout page, where the available payment methods are decided per payment rather than per invoice. Read [Payment Methods](/docs/payments/channels) before you tell a customer how to pay. The invoice moves to **Paid** and Seev emails a confirmation to both of you. The invoice keeps its completed payment details as its receipt record. It will not generate another checkout link, which is what stops a customer paying the same invoice twice. The PDF carries a payment confirmation stamp with the transaction reference and the date, so it works as the receipt. Partial payments are not supported. An invoice is either fully paid or unpaid, and there is no way to record a deposit against one. If a customer needs to pay in installments, issue a separate invoice per installment so each has its own payment record and receipt. ## Manage invoices [#manage-invoices] ## Common questions [#common-questions] No. An invoice is either fully paid or unpaid, and a deposit cannot be recorded against one. Issue a separate invoice per installment so each carries its own payment record and receipt. No. Each invoice is a one-time payment request. A retainer means one invoice per period. Check the status first. Only an invoice that is sent and unpaid can be paid, and a cancelled invoice has a deactivated link. If the status looks right, check the active organization's [verification status](/docs/organizations/verification), because live checkout creation is blocked until it is approved. Do not mark the invoice paid by hand and do not issue a replacement. A mobile money payment can sit pending while the provider settles. Follow [Payment Methods](/docs/payments/channels) for the pending case. Set the organization up, choose a payment tool, and take a first live payment. Send an amount-and-description request when line items and terms are not worth the extra minutes. # Payment Links (/docs/payments/links) A payment link is the shortest path to a single payment on Seev. You enter an amount, attach a recipient, and share a URL. There are no line items and no due date. The payer lands on the same Seev-hosted checkout that [Invoices](/docs/payments/invoices) and the [Checkout API](/docs/payments/checkout) use, so the payment methods and the payment record are identical. You know the amount and do not need line items, tax, or a due date. Itemized bills with a due date, which is an [invoice](/docs/payments/invoices). A browsable catalogue, which is a [storefront](/docs/payments/storefront). Approved [verification](/docs/organizations/verification). You can save a link before that, but it will not generate a live checkout. ## How a link moves [#how-a-link-moves] A link collects one payment. Once it is paid it moves to Paid on its own and will not generate another live checkout, which is what stops a payer being charged twice on the same URL. Live and payable. A payment succeeded. The link moves here on its own. Past the expiry date or manually deactivated. Cancelled before payment. Checkout and sharing are disabled. A generated checkout session lasts 30 minutes. A payer who opens the link after that needs a fresh checkout, so send the link close to when you expect it to be paid rather than days ahead. ## Create a link [#create-a-link] From the dashboard, go to **Payment Links → New Link**. Select the organization that should receive the money before you start, because the link is bound to whichever one is active at creation. Entered in major units, so GHS 100.00 is typed as `100.00`. Fixed at creation, and the payer cannot change it. Set at creation and not editable afterwards. Short label shown to the payer on the checkout page. Say what they are paying for. Pre-filled on the checkout page when present. When present, Seev emails the link and sends the receipt here. **Generate Link** stays disabled until a customer and an amount are both present. Copy the link and send it however suits you, over WhatsApp, SMS, email or social media. The payer needs no Seev account. Once checkout has been generated, the link's detail page carries a copyable payment URL and a **Scan to pay** QR code. Click the QR code for a fullscreen view large enough to be scanned across a counter. ## What the payer sees, and what happens when they pay [#what-the-payer-sees-and-what-happens-when-they-pay] The payer opens the link and lands on the Seev-hosted checkout page, which shows your business, the amount and your description, then the methods currently enabled for that payment. Which methods appear is decided per payment rather than per link, so read [Payment Methods](/docs/payments/channels) before you tell a payer how to pay, and open the checkout once yourself to confirm. Seev emails you when the payment succeeds, and the dashboard record updates without you refreshing it. The link moves to **Paid** and will not generate another live checkout. The sharing, QR and active-checkout controls are hidden, deliberately: a second live checkout on a paid link is a second charge. ## Manage links [#manage-links] ## Common questions [#common-questions] No. A link collects a single payment and moves to Paid on its own once one succeeds. Create a new link for the next payment. No. The amount and the currency are both fixed at creation. Cancel the link and create a new one. The page is displaying a stale record. Refresh the detail page so the current status loads. A paid link keeps its receipt while hiding the copy, QR and active-checkout actions. Do not create a replacement link. A mobile money payment can sit pending while the provider settles it. Follow [Payment Methods](/docs/payments/channels) for the pending case. A link is seconds of setup with no line items, due date or PDF. An invoice takes a few minutes and gives the customer itemized charges, terms and a downloadable PDF that doubles as the receipt. Use a link for a quick informal request and an [invoice](/docs/payments/invoices) for formal billing with a paper trail. Bill a customer with itemized line items and track the invoice through to a receipt. Give customers a catalogue to browse, a cart to fill, and a POS screen for walk-in sales. # Send Money (/docs/payments/payouts) Use **Send** on the USD balance card in the Seev Dashboard to move funds out of your account to a recipient. Sending on demand covers USD bank transfers and USDC withdrawals to Stellar wallets. Your GHS balance does not have a send option. Settled cedi funds leave on the payout schedule set for the organization, and Seev writes a payout record each time one completes. Set the schedule and destination in [Payout settings](/docs/settings/payouts), and read the completed payouts in the Activity feed on [Balances](/docs/accounts). Some send methods require approved organization verification, so a blocked transfer is often a verification problem rather than a balance problem. Check the organization's [verification status](/docs/organizations/verification) before contacting support. ## Send money [#send-money] 1. Go to **Balances** in the Seev Dashboard. 2. On the USD balance card, click **Send**. 3. Choose a send method. 4. Enter or select the recipient details. 5. Enter the amount. 6. Review the fee, the rate, the recipient details and the amount the recipient receives. 7. Enter your PIN to confirm. The review screen is the last point at which anything can be corrected. Rates and fees can change between entering an amount and confirming, so read the final screen rather than the one you saw a minute earlier, and enter your PIN only once the recipient details are right. ## Send to a bank account [#send-to-a-bank-account] Send USD to a recipient's bank account. Pick a saved bank recipient, or add a new one by supplying: | Field | Type | Required | Description | | ------------------ | ------ | -------- | ---------------------------------------------------------- | | Account owner name | string | Yes | Must match the name on the receiving account. | | Account number | string | Yes | The recipient's account number at the receiving bank. | | Routing number | string | Yes | The bank name is resolved from this value where available. | | Account type | enum | Yes | Checking or savings. | | Recipient email | string | Yes | Contact address for the recipient. | | Recipient address | string | Yes | The recipient's postal address. | If no bank name resolves from the routing number, do not proceed on the assumption that the number is right. Confirm it with the recipient first. ## Send to a stablecoin wallet [#send-to-a-stablecoin-wallet] Withdraw USDC to a Stellar wallet address. The current withdrawal flow supports one asset on one network: | Asset | Network | | ----- | ------- | | USDC | Stellar | You supply the recipient's Stellar wallet address, an optional memo ID and an optional narration. Check the address, the network and the memo before you confirm. A crypto transfer sent to the wrong address, the wrong network or without a required memo can be impossible to recover. Nobody at Seev can reverse it. Confirm all three with the recipient before entering your PIN. ## Understand the amount the recipient receives [#understand-the-amount-the-recipient-receives] The Dashboard shows the applicable fee, the exchange rate and the resulting recipient amount before you confirm. What you type in differs by method: | Send method | Amount behaviour | | | ----------------- | ----------------------------------------------------------------------------------- | ----------------------- | | Bank account | You enter the USD amount to send and review the fee before confirming. | | | Stablecoin wallet | You enter USDC, or GHS where conversion is available, and review the USDC required. | | | Mobile money | You enter a USD amount and review the GHS amount the recipient receives. | Entered in major units. | Fees and rates are shown on the review screen for the specific transfer you are about to make. Do not quote a recipient a figure from a previous transfer. ## Methods not yet available [#methods-not-yet-available] The Send Money modal shows two further options as disabled. | Method | Status | | ----------------- | ----------- | | Send to Pix | Coming soon | | Send to Moneygram | Coming soon | ## Check before you enter your PIN [#check-before-you-enter-your-pin] * Confirm bank recipient details before saving or sending. * Confirm the USDC wallet address, the Stellar network and the memo ID. * Read the fee, the rate and the recipient amount on the review screen. * Enter your PIN only after every detail above is confirmed. ## Related docs [#related-docs] * [Get Paid with Seev Plus](/docs/payments) * [Organization verification](/docs/organizations/verification) * [Developer Transactions](/docs/developer/transactions) # Storefront (/docs/payments/storefront) A Seev Storefront is a hosted online store you run entirely from the dashboard, with a public catalogue, a cart and checkout already wired up. Reach for it when customers should browse and choose products themselves. Dashboard feature No API or code integration required Customers should browse a catalogue, build a cart and check out on their own. Payments inside your own site or app, which is the [Checkout API](/docs/payments/checkout). One amount from one person, which is a [payment link](/docs/payments/links). Approved [verification](/docs/organizations/verification) before live checkout. You can build the store before that.
## Launch a store [#launch-a-store] Log in to the [Seev Dashboard](https://seevplus.com) . Go to **Storefront → Create Store** . Choose your subdomain. Add your store name, logo and a short description. Set the store's currency and preferences. Click **Launch Store** . The store is live immediately. Unique across all Seev storefronts, and what customers bookmark and share. Custom domains are not supported.
## Add products [#add-products] Add and manage products from **Storefront → Products**. Product title shown on the store. Full product description on the product page. Listed price in the store currency, entered in major units. Up to 5. At least one is required before the product can be made visible. Inventory count. The product is hidden once it reaches zero. Published or Draft. Draft products never appear on the public store. Products can be grouped into categories so the store is easier to browse. The **Active on site** switch controls public visibility, and a product with no image cannot be enabled at all. Add one by opening the product, or by clicking or dropping an image onto an empty thumbnail in the products table. The **All**, **Enabled**, **Disabled** and **Low stock** filters request matching products from the server, product lists paginate, and the public storefront loads further active products as the customer scrolls. ### Import products from a CSV [#import-products-from-a-csv] Bulk upload sits as a secondary action on the Products tab. Download the CSV template. Add product rows, with optional comma-separated image URLs. Drop or select the CSV file. Review the valid rows and the reported issues. Click or drop images onto individual preview thumbnails where a row needs one. Import the reviewed products. Imported products start inactive, so verify each one's image, price, stock and description before you publish it. A row that reports an issue is not imported: correct it in the CSV and upload again rather than assuming a partial import succeeded. ### Change many products at once [#change-many-products-at-once] Select product rows to enable quick actions such as toggling visibility, featuring products, or deleting the selection after confirmation. **Update stock** edits stock counts for active products in one scrollable view without opening each product. ## Take payment for an order [#take-payment-for-an-order] A customer who adds a product and checks out goes through the Seev-hosted payment flow, the same one used by payment links and invoices. Which methods appear is decided per payment. See [Payment Methods](/docs/payments/channels), and never promise a customer a method that is not selectable on the live checkout page. The active organization must be approved before the store can collect a live payment. A store launched under an unverified organization shows products and fails at checkout. ## Sell in person with POS [#sell-in-person-with-pos] The **POS** tab creates real storefront orders for in-person sales. Search the active product list. Add products and adjust quantities in the checkout cart. Create the POS order. Show the generated QR code for the customer to scan, or open the checkout link on the payment device. POS orders appear in the normal Orders tab with a visible POS indicator, and the **All**, **Store orders** and **POS orders** filters separate in-person from online activity. POS placeholder customer details are excluded from customer analytics, and a real customer is recognized once they provide a receipt email during payment. ## Track and fulfil orders [#track-and-fulfil-orders] Every completed purchase creates an order in **Storefront → Orders**, carrying the customer's name, email and phone; the items purchased and their quantities; the payment method and the transaction reference; and the order status. Payment initiated but not yet confirmed. Do not ship. Payment succeeded. Ready to fulfil. You marked it fulfilled. Cancelled before or after payment. Set fulfilment up from [Webhooks](/docs/developer/webhooks), and read [Verify Payments](/docs/payments/verify) for the delivery behaviour your handler has to tolerate. A `Pending` order is not a failed one. A mobile money payment can sit pending while the provider settles, so leave it alone rather than cancelling and asking the customer to order again. ## Configure the storefront [#configure-the-storefront] **Storefront Customization** and **Preferences** control: The store name, logo and description. The contact email shown to customers. The social links displayed in the footer. Whether the store is published or taken offline. Notification preferences for new orders. Where those options are available, preferences also cover the pickup address, the shipping locations, business hours, store policies and product categories. The correct organization and store are selected. Every product has a clear image, a name, a price, a description and a stock count. Only the products that should be publicly visible are enabled. You have opened the public store and walked through browsing, the cart and checkout yourself. Organization verification is complete, before expecting live checkout to work. The pickup, shipping, contact, business-hours and policy information is reviewed. ## Common questions [#common-questions] No. Custom domains are not supported, so the store URL stays on a `seevplus.store` subdomain. A product with no image cannot be enabled at all. Add one by opening the product, or by dropping an image onto its empty thumbnail in the products table. Check its stock count. A product is hidden once stock reaches zero, and a Draft product never appears publicly regardless of stock. A row that reports an issue is not imported. Correct it in the CSV and upload again rather than assuming a partial import succeeded, and remember imported products start inactive. POS placeholder details are excluded from customer analytics. A real customer is recognized once they provide a receipt email during payment, and that attaches to an existing record when the email already matches one. A URL that collects one payment, with no line items or due date. Which methods can take a live payment today, and what to tell a customer when one cannot. # Subaccounts (/docs/payments/subaccounts) A subaccount is a settlement destination owned by your organization. Attach one to a payment and Seev splits that payment between your main balance and the subaccount, each with its own payout record. Use it when someone else has a claim on part of the money: a partner studio, a branch, a vendor on a marketplace. Dashboard and API Create and manage from either, apply at checkout Part of a payment belongs to someone else and should settle to them directly rather than being paid on later. Your own second balance, or a split you want to decide after the payment. The share is fixed when the payment is created. A verified subaccount, and the [Checkout API](/docs/payments/checkout) to attach it to a payment. ## How a split works [#how-a-split-works] Fees come off the top, then both sides take their percentage. Each side receives its share of the gross, the fee and the net, so a partner on 30% carries 30% of the processing fee on that payment rather than having it absorbed for them. The share is a percentage above 0 and below 100, with at most two decimal places. A payment cannot be split 0% or 100%. The share is fixed onto the payment when it is created. Changing the subaccount's default later does not alter payments already made. Your main balance absorbs the rounding remainder, so the two shares always add back to the exact amount collected. The subaccount's cut lands in its own balance with its own payout record. Your payouts move only your own share. A payout smaller than your gross sales can still be correct. Read the payout's reconciliation summary, which separates fees, refunds and subaccount splits. See [Payout Schedules](/docs/settings/payouts). ## Create a subaccount [#create-a-subaccount] You can create a subaccount from the dashboard or through the API. Both produce the same record and the same `SUB_` code. From the dashboard, go to **Subaccounts** in the sidebar. Viewing needs the `payout_settings.read` permission and creating or pausing needs `payout_settings.manage`, so a teammate on a limited role may not see the page at all. The partner, branch or vendor this share belongs to. Shown on their payout records. A bank account, or mobile money on MTN MoMo, Telecel Cash or AT Money. The bank account number, or the mobile money phone number. The account holder. For mobile money this is looked up from the network and filled in for you once the number is complete. The percentage of each payment that goes to this subaccount. Above 0 and below 100. Defaults to GHS. It has to match the currency of any payment you attach the subaccount to. A new subaccount is created **active**, and its code is returned in the form `SUB_` followed by sixteen hex characters. That code is what you pass at checkout. Whether it starts verified depends on the destination, covered under [How a subaccount gets verified](#how-a-subaccount-gets-verified). ### Through the API [#through-the-api] The subaccount routes take the same developer key as the Checkout API, on the same host, so sandbox and production follow the key you send. Field names are snake\_case here and differ from the dashboard labels. ```bash curl -X POST \ https://api.seevplus.com/api/v1/developer/subaccounts \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "business_name": "Ama Studio", "settlement_bank": "mtn", "account_number": "0241234567", "account_name": "Ama Mensah", "default_share_percent": 30 }' ``` The partner, branch or vendor this share belongs to. A mobile money network or bank code from the [settlement codes](#settlement-codes) list. A code that is not on the list is refused. Display name for the destination. Filled in from the code when you leave it out. Bank account number, or the mobile money phone number. A mobile money number can be sent as `0241234567` or `233241234567`. The account holder. For mobile money in production this has to agree with the name the network has on record, so [look it up](#look-up-an-account-name) first. Defaults to GHS. Has to match the currency of any payment you attach it to. Above 0 and below 100, at most two decimal places. A successful create returns `201` and the subaccount, with the account number masked to its last four digits. A request Seev refuses, such as an unknown code or a name that does not match, returns `422` with the reason in `message`. ### Settlement codes [#settlement-codes] `settlement_bank` takes one of these codes. The three mobile money codes are fixed: | Destination | `settlement_bank` | | ------------ | ----------------- | | MTN MoMo | `mtn` | | Telecel Cash | `telecel` | | AT Money | `at` | Bank codes come from the live list, along with the mobile money codes above, so read them from the API rather than hard-coding them: ```bash curl https://api.seevplus.com/api/v1/developer/settlement-banks \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" ``` ```json { "success": true, "data": { "mobile_money": [ { "code": "mtn", "name": "MTN MoMo", "type": "mobile_money" }, { "code": "telecel", "name": "Telecel Cash", "type": "mobile_money" }, { "code": "at", "name": "AT Money", "type": "mobile_money" } ], "banks": [{ "code": "300304", "name": "GCB Bank", "type": "bank" }], "banks_available": true } } ``` `banks_available` is `false` on the rare occasion the bank list cannot be loaded. The mobile money codes are always returned. ### Look up an account name [#look-up-an-account-name] Before you create a mobile money subaccount, confirm who the number belongs to. The lookup returns the name the network has registered against it. ```bash curl -X POST \ https://api.seevplus.com/api/v1/developer/resolve-account \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "settlement_bank": "mtn", "account_number": "0241234567" }' ``` ```json { "success": true, "data": { "settlement_bank": "mtn", "account_number": "0241234567", "account_name": "AMA MENSAH" } } ``` Pass the returned `account_name` straight into the create request. A number the network does not recognise returns `404`. The lookup covers mobile money only; a bank code returns `422`. ### How a subaccount gets verified [#how-a-subaccount-gets-verified] **Sandbox.** Every subaccount is verified as soon as it is created, so you can test a split straight away. No money moves in sandbox. **Mobile money, production.** Seev checks `account_name` against the network's record when you create or edit the subaccount. A match verifies it on the spot. A different name is refused with `422`. **Bank account, production.** The subaccount starts as `pending` and is confirmed by review, usually within 24 hours. If the network cannot be reached during a mobile money check, the subaccount is created as `pending`. Send the same `account_number` and `account_name` in a `PATCH` to run the check again. ### Manage through the API [#manage-through-the-api] Lists the organization's subaccounts for the key's environment. Fetches one by its `SUB_` code. An unknown code returns `404`. Edits `business_name`, `settlement_bank`, `account_number`, `account_name`, `default_share_percent`, and `active` to pause or resume. Removes a subaccount that has never been attached to a payment. One with payment history returns `409`; pause it with `active: false` instead. Lists every code `settlement_bank` accepts. Returns the registered name for a mobile money number. Changing the bank, account number or account name runs verification again. Until the new destination is verified, the subaccount cannot be attached to a payment. `verification_status` is decided by Seev and cannot be set through the API. ## Read a subaccount's state [#read-a-subaccounts-state] The destination has not been confirmed. Cannot be attached to a payment yet. Confirmed. Can be attached to a payment. Set inactive from the dashboard or the API. Cannot be attached to a new payment until it is resumed. Pausing a subaccount has the same effect as leaving it unverified: a checkout request that names it is refused, so drop the `subaccount` field to take the payment in full to your main balance. Payments already split keep their share. ## Split a payment through the API [#split-a-payment-through-the-api] Pass the subaccount code as `subaccount` when you create a checkout session. Everything else about the request is unchanged. ```bash curl -X POST \ https://api.seevplus.com/api/v1/developer/payments \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "checkout", "amount": 10000, "currency": "GHS", "subaccount": "SUB_9f2k1p7qw4r8c3e1", "recipient": { "name": "Ama Mensah", "email": "ama@example.com" }, "redirect_url": "https://yourapp.com/payment/callback" }' ``` Seev resolves the code before the session is created, and rejects the whole request rather than quietly ignoring the split if any of these does not hold. The subaccount exists on the organization the API key belongs to, in the same environment as the key. The subaccount is active, not paused. The subaccount is verified. Sandbox subaccounts always pass this check. The subaccount's currency matches the payment's currency. A failure here reaches you the way every other upstream failure on this route does: a `502` whose `message` carries the upstream text, such as the subaccount being inactive, still pending verification, or holding a different currency. See [Errors and codes](/docs/errors) for how to read one. The session response carries a `split` object describing what was applied, so you can record the share against your own order: ```json { "split": { "subaccount": "SUB_9f2k1p7qw4r8c3e1", "business_name": "Ama Studio", "share_percent": 30 } } ``` Split payments cannot be paid in USDC yet. If you pass `crypto` in `channels` together with a `subaccount`, Seev leaves it out and the checkout offers your other channels; the `channels` in the response show what the customer will see. A request whose only channel is `crypto` is refused. To take USDC, create the payment without a `subaccount`. A split applies only to payments that pass the subaccount in. Invoices, payment links, storefront and POS charges created from the dashboard are not split, and their full amount stays in your own balance. ## Payouts [#payouts] A split payment is paid out twice: your share to your payout destination, and the subaccount's share to the subaccount's own destination. Both follow your payout schedule. On a GH₵1,000 payment with a 1.5% fee and a subaccount on 80%: | Paid to | Gross | Fee | Paid out | | ---------- | ------ | ----- | -------- | | You | GH₵200 | GH₵3 | GH₵197 | | Subaccount | GH₵800 | GH₵12 | GH₵788 | In the dashboard, **Accounts** lists both kinds of payout together, with a **Paid to** column that names the subaccount. ### Read payout history through the API [#read-payout-history-through-the-api] List every payout made to you and to your subaccounts, newest first: ```bash curl "https://api.seevplus.com/api/v1/developer/payouts" \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" ``` Add `subaccount` to get one subaccount's payouts, which is what you need to show a partner their own history inside your product. Use `subaccount=main` for your own payouts only. ```bash curl "https://api.seevplus.com/api/v1/developer/payouts?subaccount=SUB_9f2k1p7qw4r8c3e1" \ -H "Authorization: Bearer $SEEV_CHECKOUT_API_KEY" ``` ```json { "success": true, "data": { "payouts": [ { "id": "stlp_66f1a_20260927T000000Z_20260928T000000Z_sub_9f2k1p7qw4r8c3e1", "account_type": "subaccount", "subaccount": "SUB_9f2k1p7qw4r8c3e1", "business_name": "Ama Studio", "amount": 78800, "gross_amount": 80000, "fee_amount": 1200, "currency": "GHS", "transaction_count": 1, "status": "paid", "reference": "MOMO-20260928-118", "period": { "from": "2026-09-27T00:00:00Z", "to": "2026-09-28T00:00:00Z" }, "paid_at": "2026-09-28T09:14:00Z", "destination": { "type": "mobile_money", "network": "mtn", "account_name": "Ama Mensah", "account_number_masked": "******4567" } } ], "total": 1, "page": 1, "limit": 20, "env": "production" } } ``` A `SUB_` code, or `main` for your own payouts. Leave it out for everything. Defaults to 1. Defaults to 20, at most 100. What was paid out, in pesewas: `gross_amount` less `fee_amount`. One payout, with a `transactions` list of the payments it covers. For a subaccount payout each entry is the subaccount's share of that payment. Payouts exist in production only. A sandbox key returns an empty list, because sandbox payments move no money. ## Common questions [#common-questions] No. A payment carries at most one subaccount, and it produces exactly two allocations: your main balance and that subaccount. No. The payment uses the subaccount's default share, copied onto the session at creation. For a different percentage, create a second subaccount for the same destination with that share. Yes. Creating, listing, editing, pausing and resuming are all available from the dashboard and through the API, using the same developer key as the Checkout API. That is deliberate. The share is fixed onto each payment when it is created, so a settled split is never rewritten underneath you. The new share applies to payments created after the change. The subaccount is still `pending`. In sandbox this no longer happens: every sandbox subaccount can be attached to a payment. In production, a mobile money subaccount verifies when its `account_name` matches the network's record, and a bank subaccount verifies after review. Check that the payment was created with the `subaccount` field. A payment created without it settles in full to your main balance, and a split cannot be added afterwards. Not yet. USDC is paid straight into your USDC account, while a subaccount's share is paid out in cedis, so a split payment offers mobile money, card and bank transfer only. Yes, through the API, as long as it has never been attached to a payment. Once it has payment history it is kept for your records, and you pause it instead. Create a checkout session from your backend and verify the result before you fulfil. When money leaves your balance, where it goes, and how to read a payout against your gross sales. # Verify Payments (/docs/payments/verify)
API product Requires developer access and an API key
Seev sends the customer back to your `redirect_url` when checkout finishes, but that request proves only that someone opened a URL. Anyone who knows your callback URL can open it too, with any query string they like. Verify the payment from your server before you release goods, grant access or mark an order paid. Fulfilment is hard to reverse. Do not fulfil on the redirect, on a customer screenshot, or on a `pending` status. Fulfil on a paid status returned by the verify route or carried by a signed webhook. SDK helpers and platform plugins are not available yet, so the REST examples below are the production path today. ## Verify a session after the redirect [#verify-a-session-after-the-redirect] Look the session up by the gateway `reference` returned when you created the checkout session, or by the session reference that arrives on the redirect. This route is the SeevPlus wrapper around the gateway session lookup and takes no API key or `Authorization` header, which lets a callback route check a status without holding a secret. ```bash curl -X GET \ "https://api.seevplus.com/api/v1/developer/payments/$SESSION_REF" ``` ```javascript 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); ``` ```python 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 = 300) { throw new RuntimeException($payload['error'] ?? 'verification failed'); } $paid = in_array($payload['data']['status'], ['completed', 'success'], true); ``` ```go 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) } ``` Amounts in the response are integers in the currency's smallest unit, so the `10000` below is GHS 100.00. ```json { "success": true, "data": { "id": "checkout_xyz", "reference": "PAY-20260701-abc123", "status": "completed", "amount": 10000, "final_amount": 10000, "currency": "GHS" } } ``` | Name | Type | Required | Description | | ------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `data.id` | string | Always | Checkout session identifier. | | `data.reference` | string | Always | Gateway payment reference. Store this against your order and reconcile against it. | | `data.status` | string | Always | Raw gateway status: `completed`, `success`, `pending`, `failed` or `cancelled`. | | `data.amount` | integer | Always | Amount requested, in the currency's smallest unit, so `10000` is GHS 100.00. | | `data.final_amount` | integer | Always | Amount confirmed by the session, in the same smallest unit. Compare it against your order total before fulfilling. | | `data.currency` | string | Always | ISO 4217 code, uppercase. | Treat `completed` or `success` as paid. Treat `pending`, `failed` and `cancelled` as not paid, and treat anything you do not recognise as not paid. Calling this route also synchronizes the SeevPlus transaction record with the gateway status, and that synchronization is what triggers an outbound `payment.succeeded` or `payment.failed` webhook. A session nobody ever verifies may therefore never produce a webhook. ### Example callback handler [#example-callback-handler] ```ts // GET /payment/callback?sessionRef=PAY-20260701-abc123 export async function GET(req: Request) { const url = new URL(req.url); const sessionRef = url.searchParams.get('sessionRef') || url.searchParams.get('session_ref') || url.searchParams.get('session_id'); if (!sessionRef) { return Response.redirect('/order/failed?reason=missing_session'); } const response = await fetch( `https://api.seevplus.com/api/v1/developer/payments/${encodeURIComponent(sessionRef)}`, ); if (!response.ok) { // 404 or 502 means unknown, not failed. Do not cancel the order here. return Response.redirect('/order/pending'); } const result = await response.json(); const status = result?.data?.status; if (status === 'completed' || status === 'success') { await fulfillOrder(result.data.reference); return Response.redirect('/order/confirmed'); } return Response.redirect(`/order/failed?status=${status || 'unknown'}`); } ``` ### Handle a failed lookup [#handle-a-failed-lookup] Errors arrive as `{"error": "...", "message": "..."}` with both fields carrying the same text. | Status | `error` text | Cause | What to do | | ------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | 400 | `session reference is required` | The path segment was empty or whitespace only. | Check that your callback read a reference before calling, as in the handler above. | | 404 | `failed to fetch payment session: Failed to verify checkout session: session not found or invalid` | The reference matches no gateway session, or the session expired. | Do not fulfil and do not cancel. Compare the reference against the one you stored at creation. | | 502 | `failed to fetch payment session: ...` | The gateway did not answer the lookup. Its own text is appended. | Treat the payment as unknown, retry with a backoff, and leave the order pending in the meantime. | A failure to read a status is not a failed payment. Mapping either of these onto a cancelled order is the fastest way to lose money that a customer has already paid. ## Reconcile a payment after the fact [#reconcile-a-payment-after-the-fact] Use the Developer Transactions API when you need a payment that was not created by the request you are handling: a status page that polls, a reconciliation job, or a webhook you want to confirm against a second source. See [Developer Transactions](/docs/developer/transactions) for the active organization's searchable history. | Status | Meaning | | ----------------------- | ----------------------------------------------------- | | `completed` / `success` | Payment succeeded. Safe to fulfil. | | `pending` | Initiated but not confirmed. Do not fulfil. | | `failed` | Did not go through. The customer can retry. | | `cancelled` | Cancelled or no longer payable. Create a new request. | ## Fulfil from a webhook [#fulfil-from-a-webhook] The verify call gives the customer an answer in the browser. A webhook is what tells your backend about a payment when the customer closed the tab, lost signal, or completed a USSD prompt with no redirect involved. Use both: the callback drives your UI, the webhook drives fulfilment. Two events are delivered, `payment.succeeded` and `payment.failed`. Each request carries `X-Seev-Event-ID`, `X-Seev-Event-Type`, `X-Seev-Timestamp` and `X-Seev-Signature`, where the signature is an HMAC-SHA256 over the timestamp and the raw body using the webhook's signing secret. See [Webhooks](/docs/developer/webhooks) for endpoint setup, delivery logs and signature verification. Three properties of the current delivery behaviour will affect your handler: * Delivery is attempted once, with an 8 second timeout. A slow or briefly unavailable endpoint drops the event, and you replay it manually from the Dashboard delivery log. * Deliveries for an organization are dispatched concurrently and carry no sequence number, so events can arrive out of order. Decide fulfilment from the transaction state in the payload rather than from arrival order, and ignore an event that would move an order backwards. * Because dispatch is driven by a status synchronization, the same event can be delivered more than once. Key your handler on `X-Seev-Event-ID` and make fulfilment idempotent. ## Choose a verification method [#choose-a-verification-method] | Scenario | Method | | ------------------------------------------------------ | -------------------------------------------------- | | Showing the customer a result right after the redirect | Verify the session reference with the verify route | | Releasing goods, granting access or sending a receipt | Signed webhook event | | Looking up a past payment by reference | Developer Transactions API | | Following a USSD payment that has no redirect | Poll the verify route until the status is final | # Customers (/docs/organizations/customers) The Customers page holds the people and businesses attached to the active organization. Use it to reuse contact details when billing someone, and to look up what a paying customer has bought. The page splits into two tabs, because a contact you typed in while preparing a quote is not the same thing as someone who has actually paid you. | Tab | What it lists | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Activity customers** | People who have paid through your store, through an invoice, or through a payment link. Seev builds these records from payment activity, so you cannot create or edit them here. | | **Saved customers** | Records you added by hand, available to reuse when you create an invoice or a payment link. A saved customer may never have paid you. | ## Add a reusable customer [#add-a-reusable-customer] Open **Customers**, select **Add customer**, fill in the form, and select **Add customer** again to save. Seev switches you to the **Saved customers** tab and confirms with "Customer created successfully". Every field is required, including the ones you might expect to be optional. | Field | Type | Required | Description | | --------- | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Full name | Text | Yes | The name that appears on invoices and payment links built from this record. | | Email | Email | Yes | Also the strongest field Seev has for matching this person to later payment activity, so enter it the same way every time. | | Phone | Text | Yes | Accepts an international format such as `+233 24 000 0000`. | | Address | Text | Yes | Street address of the customer. | | City | Text | Yes | City of the customer. | | Type | Choice of **Company** or **Individual** | Yes | Defaults to Company. Choose Company when invoices should carry a company name, Individual when the buyer is a person buying for themselves. | Leaving any field blank stops the save and shows "Please fill in all customer details." Nothing is submitted, so correct the empty field and select **Add customer** again. If the save reaches Seev but fails, you get "Could not create customer. Please try again." and the modal stays open with your input intact. Retry once before assuming the record was created, then check the **Saved customers** tab so you do not save the same person twice. You can also add a customer directly from the invoice and payment-link creation forms, which saves the same record without leaving the work in progress. ## Find a customer who has paid you [#find-a-customer-who-has-paid-you] Open the **Activity customers** tab. Narrow the list with the source filter, which offers All, Store, Invoices, and API, then search by name, email, or phone in the search box. When a customer has no name attached to their payment, Seev falls back to the best identifier it holds, usually the email address, and shows the word "Customer" when it has nothing else. Search by email or phone in that case rather than by name. ## Read the customer totals [#read-the-customer-totals] Four tiles sit above the list: Total customers, Store, Invoices, and Payment links. These count activity customers by the source that produced them. They are hidden entirely until the organization has at least one activity customer, so an empty space here means no payments have been attributed yet rather than a loading failure. These are counts of people, not money. For balances, open [Accounts and Wallets](/docs/accounts). ## Open a customer record [#open-a-customer-record] Select any row to open the customer. The record shows Lifetime spend alongside counts of orders, invoices, and API payments, then lets you filter the history through the Activity, Orders, Invoices, and API payments tabs. Lifetime spend is a dashboard figure in major currency units, so GHS 100.00 displays as such rather than as a smallest-unit integer. It adds up paid invoices, orders that were not cancelled, and API payments in a successful state. Lifetime spend is labelled with a single currency, taken from the customer's first invoice, order, or API payment. If one customer has paid you in more than one currency, the tile adds those amounts together and labels the result with only one of them. Open the tabs and read the individual amounts instead of trusting the total. If the row opens on "Invalid customer", the link or URL you followed is malformed; return to Customers and select the row again. "Customer not found" means the record does not exist in the active organization, which most often means you are in the wrong workspace. See [Organizations](/docs/organizations) for switching. ## Take payment from a walk-in customer [#take-payment-from-a-walk-in-customer] Point of sale, under Store, builds a cart and creates an order that produces a checkout link and QR payment options for a walk-in buyer. The POS order carries the store currency and the cart items; it does not ask you for the buyer's name or contact details, so nothing is written to Saved customers when you create one. A payer who supplies a receipt email at POS checkout is attached to an existing customer record when that email already matches one. If POS order creation fails you get "Could not create POS order." and the cart is preserved. Check that the store has at least one active product with stock before retrying, and see [Troubleshooting](/docs/troubleshooting) for products that cannot be enabled. ## Find customers created by API payments [#find-customers-created-by-api-payments] Customers derived from Seev API transactions appear in the Developer overview, kept separate by sandbox and production, and are read-only. See [API Customers](/docs/developer/customers). ## Avoid duplicate customer records [#avoid-duplicate-customer-records] Duplicates come from small inconsistencies rather than from anything the product does wrong, so a few habits prevent almost all of them. * Search the **Saved customers** tab before adding a record. * Reuse an existing customer from the invoice and payment-link forms rather than retyping the details. * Enter the email address consistently, since it is the field most likely to link a payment to the right person. * Avoid a shared team email address on records for unrelated customers, which merges people who should stay separate. Customers are not shared between organizations. If a customer looks missing, confirm the active organization before you recreate them. ## Troubleshooting [#troubleshooting] ### Why is a saved customer missing from the activity totals? [#why-is-a-saved-customer-missing-from-the-activity-totals] The tiles and the **Activity customers** tab count payment activity. A saved customer who has not completed a payment Seev can attribute to them will not appear there, which is expected rather than a failure. ### Why can I not edit a customer in the activity list? [#why-can-i-not-edit-a-customer-in-the-activity-list] Activity customers are derived from payment records, so there is no edit control on them. Add a saved customer with the corrected details if you need a record you can reuse and change. ### Why can I not edit API customers? [#why-can-i-not-edit-api-customers] The Developer customers view is derived from transaction data and is read-only. ### Why does a customer show only an email address? [#why-does-a-customer-show-only-an-email-address] The payment carried no name. Seev shows the best identifier it has rather than leaving the row blank. # Organizations (/docs/organizations) An organization is the workspace that owns business activity in Seev Plus. Stores, products, orders, invoices, payment links, customers, balances, payout settings, verification, API resources, and the transaction PIN all belong to one organization. Your Seev login is personal and can belong to several organizations. Those organizations share nothing but you. ## Decide whether you need another organization [#decide-whether-you-need-another-organization] Create another organization when you run a genuinely separate business, brand, legal entity, or team that needs its own money, verification, customers, and reporting. Do not create one to work around a problem. A missing record, a forgotten PIN, a rejected verification, or a page that will not load are all fixed in the organization that has the problem. A new organization starts with no balances, no verification, no PIN, and no records, so it cannot recover anything from the old one. ## Create an organization [#create-an-organization] 1. Open the organization selector at the bottom of the sidebar. 2. Select **Add new organization**. 3. Enter the organization name your team will recognize. 4. Select **Create organization**. 5. Confirm the new name is the one selected before you add any data. | Field | Type | Required | Description | | ----------------- | ---- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Organization name | Text | Yes | The name your team recognizes. **Create organization** stays disabled while it is blank or only whitespace. Seev does not fall back to your personal name or email. | This is not the name customers see. That is set separately and goes through review. See [Settings](/docs/settings). ## Switch organizations safely [#switch-organizations-safely] Open the selector and choose a workspace. Seev reloads that organization's balances, activity, stores, invoices, payment links, customers, settings, verification, developer tools, and PIN status together. The currently selected organization cannot be selected again, and the selector is disabled while a switch is in progress. Check the active organization before any of these: * Creating an invoice, payment link, store, or API key * Uploading products or changing stock * Moving money or saving a payout destination * Setting or entering a transaction PIN * Completing verification If a page looks unexpectedly empty, check the active organization before recreating anything. Recreating a record that already exists in another workspace leaves you with two, and in the case of a payment request, two ways for a customer to pay. ## Read the verification banner [#read-the-verification-banner] A banner sits at the top of the dashboard while the organization is not yet approved. Its wording tells you whether you are waiting or need to act. | Banner | Meaning | Action offered | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | Verify your organization to accept payments | Verification has not been submitted. You can keep building stores, invoices, payment links, and API tools, but checkout links and live payments need approval. | **Complete profile** | | Verification submitted for review | The details are with the review team. Payment tools unlock on approval. | **View status** | | Verification needs your attention | The review did not approve the organization. The banner carries the reviewer's note when one was left. | **Review and resubmit** | The banner can be dismissed, which hides the reminder without changing anything about the organization's state. See [Organization Verification](/docs/organizations/verification). ## Know what stays separate [#know-what-stays-separate] Each organization holds its own verification record and its own approval. Approval in one unlocks nothing in another. Each also has its own balances, activity, payout settings, and transaction PIN. Switching organizations changes all of them at once, which is why a PIN that worked a moment ago can stop working after a switch. Roles and permissions are also per organization. The same person can be an owner in one and a limited member in another. See [Team Access](/docs/settings/team) for roles, custom roles, and the permission keys behind them. ## Troubleshooting [#troubleshooting] ### Why is a new organization empty? [#why-is-a-new-organization-empty] Nothing is copied between organizations. An empty new workspace is working correctly. ### Why can one organization accept payments when another cannot? [#why-can-one-organization-accept-payments-when-another-cannot] Their verification records are separate and are probably in different states. Check the banner on the organization that cannot. ### Why does my PIN work in one organization only? [#why-does-my-pin-work-in-one-organization-only] The transaction PIN belongs to the organization, not to you. This is deliberate, so that access to one workspace's funds does not extend to another's. See [Transaction PIN](/docs/accounts/pin). ### Why has a sidebar item disappeared after switching? [#why-has-a-sidebar-item-disappeared-after-switching] Navigation is filtered by the permissions you hold in the organization you switched into, and two items have extra conditions: Cards appears only for organizations verified as a business, and Yield stays hidden until a yield account exists. See [Team Access](/docs/settings/team). ### What happens when a teammate leaves? [#what-happens-when-a-teammate-leaves] Remove their membership. The organization keeps every business record. Rotate anything they knew, such as API keys, and change the transaction PIN if they had it. # Organization Verification (/docs/organizations/verification) Verification belongs to an organization, not to the person completing it. If you belong to several organizations, each has its own type, progress, review state, and approval, and approval in one unlocks nothing in another. Choosing a type and submitting for review needs the `organization.verification.manage` permission. Any active member can read the status. See [Team Access](/docs/settings/team). ## Choose a verification type [#choose-a-verification-type] The first time you open verification for an organization, choose the profile matching how it operates. Seev stores this on the organization as `business` or `individual`. | Type | Stored value | Suited to | Information gathered | | ---------------------------- | ------------ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | **Registered business** | `business` | Incorporated companies and registered businesses | Business information, business address, owners or controlling people, and company documents | | **Individual or contractor** | `individual` | Freelancers, creators, contractors, and sole operators without company registration documents | Personal information, residential address, and identity documents | The type stays attached to the organization, so returning to finish a draft does not ask again. The type has a lasting consequence beyond the review itself: Cards appears in the sidebar only for organizations verified as a business. An organization verified as an individual does not get card access. See [Cards](/docs/accounts/cards). ## Track the review state [#track-the-review-state] Verification moves through five states. | Status | Meaning | What you can do | | ------------------ | ----------------------------------------------------- | ---------------------------------------------------- | | `not_started` | No type has been chosen. | Choose registered business or individual. | | `draft` | Started, with required modules still incomplete. | Keep editing and uploading. | | `ready_for_review` | Everything required has been submitted. | Track the review. Editing is locked while it runs. | | `approved` | The organization passed. | Use the features approval unlocks. | | `rejected` | The review needs corrected or additional information. | Read the note, update the existing record, resubmit. | A rejected verification is corrected in place. Do not choose a new type or create another organization to retry, since a new organization starts with nothing and cannot inherit the work already done. Open the existing verification, follow the note, and resubmit. The dashboard banner reflects these states in plainer language, and carries the reviewer's note when a review was rejected. See [Organizations](/docs/organizations) for what each banner offers. ## Understand what completion means [#understand-what-completion-means] The verification page calculates completion from the required modules for your type. A registered business completes business information, address, ownership, and document modules; an individual completes personal information, address, and identity document modules. Completion at 100% means the information is ready to submit. It is not approval. The organization is approved only when the status itself becomes `approved`, which happens after a person reviews the submission. ## Know what approval unlocks [#know-what-approval-unlocks] Approval is the organization-level permission Seev checks before live money movement. On approval the organization is marked verified, its GHS wallet is provisioned, its legal name is synced from the verification record, and it is emailed. These are gated on approval: * Store checkout and payment-link checkout. Until approval, the refusal explains that the merchant must complete organization KYC before customers can check out or generate payment links. * Production developer and API access. * Activation of the organization's USD receiving account. See [Virtual Accounts](/docs/accounts/virtual-accounts). * Requesting a customer-facing business name. See [Settings](/docs/settings). * Setting the organization's transaction PIN, which becomes available once verification is submitted for review or approved. See [Transaction PIN](/docs/accounts/pin). Creating an invoice, a product, a store, or a sandbox integration does not mean the organization can collect live money. Check the organization's verification state rather than inferring it from what you were able to build. ## Confirm the organization before you start [#confirm-the-organization-before-you-start] Verification opens against whichever organization is active. Details entered for one organization never become another's record. If information from a different organization appears, stop. Return to Seev Plus, confirm the organization selector, and reopen verification from that organization's banner. Do not overwrite the form while the wrong organization is active, since you would be editing that organization's real record. ## Next steps [#next-steps] # Complete Verification (/docs/organizations/verify) Open Seev Plus, select the organization you want to verify, and use the verification prompt on the dashboard banner. Seev opens the verification app carrying the selected organization and your signed-in session. Before starting, read [Organization Verification](/docs/organizations/verification) to choose the right type, since the type determines what you will be asked for and cannot be swapped later to retry a rejection. ## Prepare what you will be asked for [#prepare-what-you-will-be-asked-for] Gather the information for your type before you start. Half-finished uploads are the most common reason a verification stalls in `draft`. | Registered business | Individual or contractor | | ----------------------------------------------- | ---------------------------------------------------------- | | Registered business name and business email | Legal name and contact details | | Registered or operating address | Residential address | | Formation date and tax identifier | Government-issued identity document | | Owners or controlling people | A business or contractor email | | Incorporation, ownership, and address documents | Any additional document the verification provider requests | Use an email associated with the business or the contractor work. One active verification profile uses one email at a time. Documents are rejected for quality far more often than for substance, so check each image before uploading: * The document is valid and not expired. * All four corners are visible where that applies. * The text is sharp enough to read without zooming. * There is no glare, heavy shadow, or obstruction. * The legal name and identifier you typed match what the document shows. ## Work through the flow [#work-through-the-flow] Save each step before moving to the next. Each save updates the organization's completion, so you can leave and return while the status is `draft`, and Seev restores what that organization had saved. If a save fails, stay on the step and read the message on the form. Correct the highlighted field and save again rather than reloading, and never refresh during a document upload, which loses the upload without recording it. ## Submit for review [#submit-for-review] When completion reaches 100%, review everything. Use **Go back and review** to change an earlier step, then **Submit for review** when it is correct. Submission locks the form. The status becomes `ready_for_review` and normal editing stops until a reviewer either approves the organization or rejects it. Check the details before submitting rather than planning to amend them afterwards. Seev Plus swaps the incomplete-verification banner for an under-review notice once submission succeeds. That change of banner is your confirmation; if the old banner persists, the submission did not go through. ## Correct a rejection [#correct-a-rejection] A rejected verification shows the reviewer's note on the dashboard banner and in the verification app. Use **Update verification** to reopen the existing flow with your saved information prefilled, correct what the note asks for, complete any remaining steps, and submit again. Do not choose a new verification type and do not create another organization. Neither carries your saved work, and a new organization starts with no balances, no PIN, and no records. Corrections usually amount to one of these: * Replacing a document that could not be read. * Entering the registered name exactly as it appears on company records. * Correcting an address or tax identifier that did not match the documents. * Supplying a document the note asked for specifically. ## After approval [#after-approval] When the status becomes `approved`, the organization is marked verified, its GHS wallet is provisioned, and its legal name is taken from the verification record. Live payment acceptance, checkout links for invoices and payment links, eligible payout tools, and production developer access then read that approved status. Approval applies only to the organization you verified. Switching organizations switches the verification state and the feature access along with it. If a restricted action does not unlock straight away, reload Seev Plus once before assuming approval did not apply. ## Troubleshooting [#troubleshooting] ### I chose a type but the form does not open [#i-chose-a-type-but-the-form-does-not-open] Reload the verification page once. If a draft already exists, use **Continue verification**, which takes you into the flow matching the type saved for that organization. ### My completion says 100% but the organization is not verified [#my-completion-says-100-but-the-organization-is-not-verified] Completion measures the steps you filled in, not the review. Use **Submit for review** and wait for the status to move from `ready_for_review` to `approved`. ### I need to change something before submitting [#i-need-to-change-something-before-submitting] On the review screen, choose **Go back and review**. After submission, editing is locked unless the review is rejected. ### I switched organizations mid-way [#i-switched-organizations-mid-way] Return to Seev Plus and reopen verification from the active organization's banner. Every organization has a separate record, and information entered under the wrong one edits that organization's real verification. ### A teammate cannot open verification [#a-teammate-cannot-open-verification] Choosing a type and submitting need the `organization.verification.manage` permission. Reading the status does not. See [Team Access](/docs/settings/team). # Payouts API (/docs/payouts)
⬡ API product Requires an API secret key with the `payments:write` scope
The Payouts API withdraws money from your organization's Seev balance to a mobile money recipient, initiated by your own server rather than from the Dashboard. It is the programmatic counterpart to the Dashboard [Send Money](/docs/payments/payouts) flow. A payout debits your balance the moment you create it, so the request is authorized with a `payments:write` key and every create must carry an `Idempotency-Key`. This page assumes you already hold a key. If you do not, create one first in [API Keys](/docs/developer/api-keys), and read [Idempotency](/docs/idempotency) before you send money. A payout moves real money to a recipient. Amounts are expressed in the currency's smallest unit, so `10000` means GHS 100.00. Send the wrong figure and the funds leave your balance. Confirm the amount, the channel, and the recipient before you call this endpoint. ## Scope and limits at launch [#scope-and-limits-at-launch] | Property | Value | Description | | -------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Source balance | Main balance | Payouts debit your organization's main balance only. Subaccount balances cash out through the settlement flow, not this API. | | Currency | `GHS` | The payout currency is GHS. A payout currency must match the balance currency. | | Channels | `mobile_money` | Payouts are sent to a mobile money wallet. | | Environment | `sandbox`, `production` | Determined by the key you authenticate with. Sandbox payouts move no real money and never touch production balances. | ## Check your balance before a payout [#check-your-balance-before-a-payout] Read your balance first so a payout is not rejected for insufficient funds. The balance endpoint is authorized with a `payments:read` key. ```bash curl "https://api.seevplus.com/api/v1/merchants/balance" \ -H "Authorization: Bearer $SEEV_API_KEY" ``` ```json { "total_deposits": 5000.0, "total_withdrawals": 1200.0, "total_fees": 38.0, "total_merchant_fees": 12.0, "available_balance": 3750.0, "pending_balance": 100.0, "paid_settlements": 0.0, "reserved_settlements": 0.0, "currency": "GHS", "transaction_count": 42, "balance_source": "wallet" } ``` | Field | Type | Required | Description | | ---------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `available_balance` | number | Yes | Spendable balance in **major units** (GHS, not pesewas). This is the figure a payout draws against, and it already excludes funds held by in-flight payouts and settlement reservations. | | `pending_balance` | number | Yes | Funds currently held for in-flight payouts and reservations, in major units. Present as `0` while the balance is read from the legacy derived source. | | `currency` | string | Yes | The balance currency, `GHS`. | | `total_deposits` | number | Yes | Lifetime deposits credited to the balance, in major units. | | `total_withdrawals` | number | Yes | Lifetime withdrawals from the balance, in major units. | | `total_fees` | number | Yes | Total fees charged, in major units. | | `total_merchant_fees` | number | Yes | The merchant-borne portion of fees, in major units. | | `paid_settlements` | number | Yes | Settlements already paid out, in major units. | | `reserved_settlements` | number | Yes | Settlements reserved and not yet paid, in major units. | | `transaction_count` | number | Yes | Number of transactions contributing to the balance. | | `balance_source` | string | No | `wallet` when the authoritative wallet balance is reported. Absent when the balance is derived from the legacy source. | The balance endpoint returns amounts in **major units** (`3750.0` means GHS 3,750.00), while the Payouts API takes and returns amounts in the smallest unit (`10000` means GHS 100.00). Convert before you compare a balance figure against a payout amount: multiply the balance by 100, or divide the payout amount by 100. Check that `available_balance` covers your payout `amount` plus the withdrawal fee before you create the payout. Because `available_balance` already excludes funds held by earlier in-flight payouts, reading it immediately before each payout is the reliable way to avoid a `422 INSUFFICIENT_BALANCE`. ## Create a payout [#create-a-payout] Send the request from your server with an `Idempotency-Key`. The amount is in the currency's smallest unit, and the merchant is charged the withdrawal fee on top of the amount, so your available balance must cover `amount + merchant fee`. ```bash curl -X POST "https://api.seevplus.com/api/v1/merchants/payouts" \ -H "Authorization: Bearer $SEEV_API_KEY" \ -H "Idempotency-Key: payout_order_123_attempt_1" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "channel": "mobile_money", "recipient": { "name": "Kwame Asante", "phone": "0551234567", "network": "MTN" } }' ``` A successful create returns `202 Accepted`. The payout is now `processing`; the funds are already reserved against your balance, and the transfer resolves asynchronously (see [Follow a payout to completion](#follow-a-payout-to-completion)). ### Request headers [#request-headers] | Header | Type | Required | Description | | ----------------- | ------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Authorization` | string | Conditional | `Bearer `. Required unless `X-API-Key` is sent. | | `X-API-Key` | string | Conditional | The secret key with no scheme prefix. Takes precedence over `Authorization`. | | `Idempotency-Key` | string | Yes | Retrying with the same key returns the original payout instead of sending a second one. Persisted per organization, so it is safe across restarts. Send a fresh key only for a genuinely new payout. | | `Content-Type` | string | Yes | `application/json`. | Always send an `Idempotency-Key` on a payout create. Without a stable key, a network retry or a double-click can send the money twice, and a mobile money transfer is not something you can quietly undo. ### Request body [#request-body] | Field | Type | Required | Description | | ------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------- | | `amount` | number | Yes | The amount to pay the recipient, in the currency's smallest unit and greater than zero. `10000` means GHS 100.00. | | `channel` | string | Yes | `mobile_money`. Any other value is rejected. | | `recipient` | object | Yes | The destination. | | `recipient.name` | string | Yes | The recipient's name. | | `recipient.phone` | string | Yes | Mobile money wallet number. | | `recipient.network` | string | Yes | Mobile money network, such as `MTN`, `VODAFONE`, `TELECEL`, or `AIRTELTIGO`. | A `mobile_money` payout requires the recipient's `phone` and `network`. Omitting either returns `400 VALIDATION_ERROR`. ## Read a payout response [#read-a-payout-response] Create, get, and list all return the same payout object. It carries the status, the amount, and the fee breakdown you are charged, and it deliberately omits the upstream provider name, provider reference, and raw provider payloads. ```json { "id": "3f1c9a2e-...", "reference": "PAY-20260915-3f1c9a2e-...", "type": "payout", "env": "production", "status": "processing", "currency": "GHS", "amount": 10000, "final_amount": 10000, "channels": ["mobile_money"], "phone": "0551234567", "network": "MTN", "recipient": { "name": "Kwame Asante", "phone": "0551234567" }, "transactions": [ { "id": "b8e0...", "channel": "mobile_money", "status": "processing", "amount": 10000, "currency": "GHS", "gross_amount": 10000, "fee_amount": 100, "net_amount": 9900, "fee_bearer": "merchant", "customer_fee": 0, "merchant_fee": 100, "failure_reason": "", "created_at": "2026-09-15T10:12:00Z", "updated_at": "2026-09-15T10:12:00Z" } ], "expires_at": null, "completed_at": null, "created_at": "2026-09-15T10:12:00Z", "updated_at": "2026-09-15T10:12:00Z" } ``` | Field | Type | Required | Description | | -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------ | | `id` | string | Yes | The payout's unique identifier. | | `reference` | string | Yes | The payout reference. Use this to fetch or reconcile the payout, and store it against your own record. | | `type` | string | Yes | Always `payout`. | | `env` | string | Yes | `sandbox` or `production`, matching the key that created it. | | `status` | string | Yes | `processing`, `completed`, or `failed`. See the status table below. | | `currency` | string | Yes | The payout currency, `GHS`. | | `amount` | number | Yes | The amount sent to the recipient, in the smallest unit. | | `final_amount` | number | Yes | The amount the payout settled for, in the smallest unit. | | `channels` | array | Yes | The channel used, `["mobile_money"]`. | | `phone` | string | No | The mobile money number, when the channel is mobile money. | | `network` | string | No | The mobile money network, when the channel is mobile money. | | `recipient` | object | Yes | The recipient's `name` and, for mobile money, `phone`. | | `transactions` | array | Yes | The disbursement attempts, each with its own status and fee breakdown. | | `expires_at` | string | No | When set, when the payout stops being actionable. | | `completed_at` | string | No | When the payout reached a final status. | | `created_at` | string | Yes | When the payout was created. | | `updated_at` | string | Yes | When the status last changed. | The `transactions[]` entries expose the fee math you are charged (`gross_amount`, `fee_amount`, `net_amount`, `merchant_fee`, `customer_fee`, `fee_bearer`) and a `failure_reason` when a transfer fails. In the withdraw direction the recipient absorbs the customer-borne portion of the fee, and only the merchant-borne fee is added to what your balance must cover. ## Follow a payout to completion [#follow-a-payout-to-completion] A payout create returns `202` and a `processing` status. The transfer itself completes asynchronously, and Seev resolves the payout when the provider confirms the result. Poll the payout by reference, or wait for the corresponding webhook, rather than treating the `202` as proof the money arrived. ```bash curl "https://api.seevplus.com/api/v1/merchants/payouts/PAY-20260915-3f1c9a2e-..." \ -H "Authorization: Bearer $SEEV_API_KEY" ``` | Status | Cause | What to do | | ------------ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `processing` | The payout was created and the reserved funds are held while the transfer runs. No final result has arrived. | Keep the payout open and check again. A payout stuck in `processing` for an unusually long time is being re-checked by Seev; do not create a replacement. | | `completed` | The provider confirmed the transfer and the held funds have left your balance. | The recipient has been paid. Retain the reference for your records. | | `failed` | The transfer did not go through. The held funds are returned to your available balance. | Read the transaction `failure_reason`, correct the recipient details if needed, and create a new payout with a new `Idempotency-Key`. | Do not create a second payout while the first is still `processing`. The original funds are already reserved, and a replacement can send the money twice. Wait for `completed` or `failed`, or fetch the payout by reference to confirm its current status, before you retry. ## List payouts [#list-payouts] Fetch a paginated history for the authenticated organization, newest first. ```bash curl "https://api.seevplus.com/api/v1/merchants/payouts?limit=20&offset=0" \ -H "Authorization: Bearer $SEEV_API_KEY" ``` | Query | Type | Required | Description | | -------- | ------ | -------- | ------------------------------------------------------------------------ | | `limit` | number | No | Page size. Defaults to `20`; values outside `1`–`100` fall back to `20`. | | `offset` | number | No | Number of payouts to skip. Defaults to `0`. | The response wraps the payout objects with a total count: ```json { "payouts": [ { "reference": "PAY-...", "status": "completed" } ], "total": 42 } ``` ## Fix a rejected payout [#fix-a-rejected-payout] Errors return a code and a message. Branch on the HTTP status; the message is a human-readable detail for your logs, not a stable contract. | Status | Code | Cause | What to do | | ------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `400` | `VALIDATION_ERROR` | The body is malformed, `amount` is not greater than zero, `channel` is not `mobile_money`, or the recipient is missing `phone` or `network`. | Fix the request and resend. Do not retry it unchanged. | | `401` | `UNAUTHORIZED` | The request reached the endpoint without a valid organization context, or the key is not accepted. | Check the key, its product, and its environment against [API Keys](/docs/developer/api-keys). | | `422` | `INSUFFICIENT_BALANCE` | Your available balance does not cover `amount + merchant fee`. | Fund the balance or lower the amount, then retry. Reserved funds from in-flight payouts reduce what is available. | | `404` | `NOT_FOUND` | The payout reference does not belong to this organization and environment. | Confirm the reference and that you are using the same environment that created it. | | `500` | `PAYOUT_FAILED` | The disbursement provider rejected the request when the payout was created. The reserved funds are released back to your balance. | Retry with a new `Idempotency-Key` once the underlying issue is resolved. | An `INSUFFICIENT_BALANCE` check counts money already reserved by in-flight payouts as unavailable. Two rapid payouts for the same funds serialize, and the second sees the reduced balance and is rejected. This is what stops a balance from being spent twice. ## Check before you send money in production [#check-before-you-send-money-in-production] * Send a stable `Idempotency-Key` on every create, derived from your own order or payout intent plus an attempt counter. * Read `GET /api/v1/merchants/balance` and confirm `available_balance` covers `amount + merchant fee`, converting for the major-unit difference. * Send the recipient fields mobile money requires: `phone` and `network`. * Treat the `202` as "accepted", not "paid". Confirm `completed` by polling the reference or handling the webhook before you tell anyone the recipient was paid. * Never create a replacement while a payout is still `processing`. * Store the payout `reference` against your own record for reconciliation. ## Related docs [#related-docs] * [Send Money](/docs/payments/payouts) * [Developer API Keys](/docs/developer/api-keys) * [Developer Transactions](/docs/developer/transactions) * [Developer Webhooks](/docs/developer/webhooks) * [Idempotency](/docs/idempotency) * [Errors and codes](/docs/errors) # On/Off-Ramp API (/docs/ramp)
⬡ API product Requires merchant activation and an API secret key
The Ramp API lets merchants integrate USDC ↔ GHS (Ghana Cedis) conversion into their own applications. Users can buy USDC with Mobile Money (on-ramp) or sell USDC for Mobile Money (off-ramp) through a simple REST API. Ramp API access requires merchant activation by SeevCash. Contact support to enable on/off-ramp for your account. ## Supported corridors [#supported-corridors] | Direction | User pays | User receives | Payment method | | --------- | ------------------ | ------------------ | ------------------------- | | On-ramp | GHS (Mobile Money) | USDC (Stellar) | MTN, Vodafone, AirtelTigo | | Off-ramp | USDC (Stellar) | GHS (Mobile Money) | MTN, Vodafone, AirtelTigo | ## How it works [#how-it-works] ### On-ramp (GHS → USDC) [#on-ramp-ghs--usdc] ``` Your App Ramp API User Stellar │ │ │ │ │── KYC ─────────▶│ │ │ │◀── verified ────│ │ │ │ │ │ │ │── create order ▶│ │ │ │◀── reference ───│ │ │ │ │── MoMo prompt ──▶│ │ │ │◀── approves ─────│ │ │ │ │ │ │ │── send USDC ─────────────────────▶ │ │◀── completed ───│ │ │ ``` 1. **KYC** — Verify the user's phone number via OTP (one-time setup) 2. **Create order** — Specify amount, user details, and destination Stellar wallet 3. **Collect** — API sends a Mobile Money prompt to the user's phone 4. **Settle** — Once fiat is confirmed, USDC is sent to the user's Stellar wallet ### Off-ramp (USDC → GHS) [#off-ramp-usdc--ghs] ``` Your App Ramp API User Stellar │ │ │ │ │── create order ▶│ │ │ │◀── merchant │ │ │ │ address ──────│ │ │ │ │ │ │ │── user sends USDC ────────────────────────────────▶ │ │ │ │ │ │── confirm ─────▶│ │ │ │ │── MoMo payout ──▶│ │ │◀── completed ───│ │ │ ``` 1. **Create order** — Specify amount and user details 2. **Send USDC** — User sends USDC to the merchant's Stellar address (returned in the order) 3. **Confirm crypto** — Call the confirm endpoint with the source wallet (tx is auto-detected) 4. **Payout** — API disburses GHS to the user's Mobile Money ## Authentication [#authentication] All Ramp API requests require a secret API key in the `Authorization` header: ```bash Authorization: Bearer sk_live_your_secret_key ``` | Environment | Base URL | | ----------- | ----------------------------------- | | Sandbox | Provided during merchant activation | | Production | Provided during merchant activation | Your API base URL is provided when your merchant account is activated for ramp services. All examples below use `https://api.your-domain.com` as a placeholder. ## Rate limits [#rate-limits] The API is rate-limited to 100 requests per second per merchant. Exceeding the limit returns `429 Too Many Requests`. ## Order lifecycle [#order-lifecycle] Every ramp order moves through a deterministic status lifecycle: | Status | Meaning | | ------------------ | ----------------------------------------------------------- | | `quote_locked` | Rate locked, waiting for payment initiation | | `fiat_pending` | MoMo collection initiated, awaiting user approval (on-ramp) | | `crypto_pending` | Waiting for user to send USDC (off-ramp) | | `fiat_confirmed` | Fiat received, processing crypto send (on-ramp) | | `crypto_confirmed` | USDC received, processing fiat payout (off-ramp) | | `crypto_sending` | USDC being sent on-chain (on-ramp) | | `fiat_sending` | MoMo payout being sent (off-ramp) | | `completed` | Order fully settled | | `expired` | Rate TTL elapsed before payment | | `cancelled` | Order cancelled by merchant or user | | `failed` | Unrecoverable error (see `failure_reason`) | ## Integration steps [#integration-steps] ## Quick start example [#quick-start-example] Here's a complete on-ramp flow in three API calls: ```bash # 1. Verify user's phone (one-time) curl -X POST https://api.your-domain.com/api/v1/ramp/kyc/initiate \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"first_name":"Kofi","last_name":"Mensah","phone":"0241234567","provider_name":"MTN"}' # 2. Verify OTP (sent to user's phone) curl -X POST https://api.your-domain.com/api/v1/ramp/kyc/verify \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"kyc_id":"","otp_code":"123456"}' # 3. Create on-ramp order (GHS → USDC) curl -X POST https://api.your-domain.com/api/v1/ramp/orders \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "onramp", "fiat_amount": 100, "destination_wallet": "GBUSER...STELLARADDRESS", "payment_method": "mobile_money", "user": { "first_name": "Kofi", "last_name": "Mensah", "phone": "0241234567", "network": "MTN" } }' ``` ## Limits [#limits] Default limits apply per merchant. Contact Seev support if your organization needs different limits: | Limit | Default | | ----------------------- | ----------------------- | | Minimum per transaction | 10 GHS | | Maximum per transaction | Configured per merchant | | Daily volume | Configured per merchant | | Monthly volume | Configured per merchant | | Minimum USDC | 1 USDC | # KYC Verification (/docs/ramp/kyc) Before a user can create ramp orders, their phone number must be verified via OTP (One-Time Password). This is a lightweight KYC step that ensures the Mobile Money account belongs to the user. KYC verification is required once per phone number per merchant. After verification, the user can create unlimited orders (subject to limits) without re-verifying. ## How it works [#how-it-works] 1. **Initiate** — Your app sends the user's details. An OTP is sent to their phone. 2. **Verify** — The user enters the OTP code. You submit it to confirm verification. 3. **Done** — The phone is marked as verified. The user can now create ramp orders. ## Step 1: Initiate KYC [#step-1-initiate-kyc] ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/kyc/initiate \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Kofi", "last_name": "Mensah", "phone": "0241234567", "provider_name": "MTN" }' ``` ### Request body [#request-body] | Field | Type | Required | Description | | --------------- | ------ | -------- | --------------------------------------------------------------- | | `first_name` | string | Yes | User's first name | | `last_name` | string | Yes | User's last name | | `phone` | string | Yes | User's phone number (local format, e.g. `0241234567`) | | `provider_name` | string | Yes | Network provider: `MTN`, `VODAFONE`, `TELECEL`, or `AIRTELTIGO` | ### Response [#response] ```json { "success": true, "data": { "kyc_id": "1875fe99-4d75-4883-974e-418cb4e221f7", "status": "otp_sent" } } ``` An SMS with a 6-digit OTP is sent to the user's phone. Store the `kyc_id` — you need it for verification. ### Already verified [#already-verified] If the phone number was already verified for this merchant, the API returns immediately without sending a new OTP: ```json { "success": true, "data": { "kyc_id": "1875fe99-4d75-4883-974e-418cb4e221f7", "status": "already_verified" } } ``` ## Step 2: Verify OTP [#step-2-verify-otp] Collect the OTP code from the user and submit it: ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/kyc/verify \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "kyc_id": "1875fe99-4d75-4883-974e-418cb4e221f7", "otp_code": "482910" }' ``` ### Request body [#request-body-1] | Field | Type | Required | Description | | ---------- | ------ | -------- | ---------------------------------------------- | | `kyc_id` | string | Yes | The KYC ID returned from the initiate step | | `otp_code` | string | Yes | The 6-digit OTP code the user received via SMS | ### Success response [#success-response] ```json { "success": true, "data": { "kyc_id": "1875fe99-4d75-4883-974e-418cb4e221f7", "phone_verified": true, "account_name": "Kofi Mensah" } } ``` ## Using the verified phone in orders [#using-the-verified-phone-in-orders] After verification, use the same phone number in your ramp order requests. The API will match it against the verified KYC record: ```json { "type": "onramp", "fiat_amount": 100, "destination_wallet": "GBXYZ...", "payment_method": "mobile_money", "user": { "first_name": "Kofi", "last_name": "Mensah", "phone": "0241234567", "network": "MTN" } } ``` If the phone hasn't been verified, the order will fail with `KYC_REQUIRED`. ## Error codes [#error-codes] | Error | Meaning | | ------------- | --------------------------------------------------- | | `OTP_FAILED` | Failed to send OTP — check phone number and network | | `NOT_FOUND` | KYC record not found for the given `kyc_id` | | `OTP_INVALID` | The OTP code is incorrect or expired | ## Best practices [#best-practices] * Check the initiate response for `"status": "already_verified"` to skip the OTP step for returning users * OTP codes expire after a few minutes — prompt the user to enter it promptly * If the OTP fails, call `/kyc/initiate` again to send a new code * Store the verification status in your app so you only call KYC for new users * The `provider_name` must match the user's actual MoMo network for the OTP to be delivered # Off-Ramp (Sell USDC) (/docs/ramp/offramp) The off-ramp flow lets users sell USDC for Ghana Cedis. Your app creates an order, the user sends USDC to the merchant's Stellar address, you confirm the crypto payment, and GHS is automatically disbursed to the user's Mobile Money wallet. ## Prerequisites [#prerequisites] * User must have completed [KYC verification](/docs/ramp/kyc) * Merchant must have an active GHS wallet with sufficient balance for the payout * User must have USDC on the Stellar network ready to send ## Create an off-ramp order [#create-an-off-ramp-order] ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/orders \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "offramp", "crypto_amount": 10, "payment_method": "mobile_money", "payout_method": "mobile_money", "idempotency_key": "offramp_xyz_456", "user": { "first_name": "Ama", "last_name": "Asante", "phone": "0542853417", "network": "MTN" } }' ``` ### Request body [#request-body] | Field | Type | Required | Description | | ----------------- | ------ | -------- | ------------------------------------------------------------- | | `type` | string | Yes | Must be `"offramp"` | | `crypto_amount` | number | One of | Amount in USDC the user sends (specify this OR `fiat_amount`) | | `fiat_amount` | number | One of | Amount in GHS the user wants to receive | | `payment_method` | string | Yes | `"mobile_money"` | | `payout_method` | string | No | `"mobile_money"` (default) | | `idempotency_key` | string | No | Unique key to prevent duplicate orders | | `user.first_name` | string | Yes | User's first name (must match KYC) | | `user.last_name` | string | Yes | User's last name | | `user.phone` | string | Yes | User's phone to receive GHS payout | | `user.network` | string | Yes | MoMo network: `MTN`, `VODAFONE`, `TELECEL`, or `AIRTELTIGO` | ### Response [#response] ```json { "success": true, "data": { "reference": "RAMP-20260810-6664a946", "order_id": "3fb1a533-f292-4764-a197-3df749f41bd0", "type": "offramp", "status": "crypto_pending", "you_pay": "10.00 USDC", "you_receive": "117.80 GHS", "rate": "1 USDC = 11.78 GHS", "fiat_amount": 117.8, "fiat_currency": "GHS", "crypto_amount": 10, "crypto_currency": "USDC", "rate_applied": 11.78, "fee_amount": 1.77, "fee_bearer": "customer", "net_fiat_amount": 117.8, "net_crypto_amount": 10, "merchant_stellar_address": "GC24GD7HFXFP4JBFNGKWSGDNNHRXLLY32NNNXSWDE24PBZUFZ5T5XNN5", "expires_at": "2026-08-10T06:40:04Z", "message": "Send 10.00 USDC to the merchant address below within 5 minutes." } } ``` The response includes `merchant_stellar_address` — this is where the user must send their USDC. The order will expire if crypto is not confirmed before `expires_at`. ## Step 2: User sends USDC [#step-2-user-sends-usdc] The user sends the exact USDC amount to the `merchant_stellar_address` returned in the order response. Your app should display this address clearly and instruct the user to send the correct amount. ## Step 3: Confirm the crypto payment [#step-3-confirm-the-crypto-payment] Once the user has sent USDC, call the confirm endpoint. The API will search for a matching payment on-chain automatically: ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/orders/RAMP-20260810-6664a946/confirm-crypto \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"source_wallet": "GCGES3SODXJX6RYF2ATRQIA5GLWDXTEXR6QWX3JERVCDY4EIFEMFB55O"}' ``` ### Request body [#request-body-1] | Field | Type | Required | Description | | --------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- | | `source_wallet` | string | Yes | The Stellar address the user sent USDC from | | `tx_hash` | string | No | Specific transaction hash (if known). If omitted, the API searches for a matching payment automatically. | ### Response [#response-1] ```json { "success": true, "data": { "reference": "RAMP-20260810-6664a946", "status": "crypto_confirmed", "tx_hash": "a3f59723bc57101d16b322b4cd7b6948b4eefab735eca0654322d48f4c28007a", "message": "Crypto payment verified. Fiat payout initiated." } } ``` ## What happens after confirmation [#what-happens-after-confirmation] 1. **Crypto validated** — USDC payment is verified on the Stellar network 2. **Fiat payout initiated** — GHS is sent to the user's Mobile Money 3. **Completed** — Order status moves to `completed` The fiat payout typically arrives within seconds for MTN Mobile Money. Bank transfers may take longer. ## Check order status [#check-order-status] ```bash curl https://api.your-domain.com/api/v1/ramp/orders/RAMP-20260810-6664a946 \ -H "Authorization: Bearer $SECRET_KEY" ``` **Completed response:** ```json { "success": true, "data": { "reference": "RAMP-20260810-6664a946", "type": "offramp", "status": "completed", "crypto_tx_hash": "a3f59723bc57101d16b322b4cd7b6948b4eefab735eca0654322d48f4c28007a", "fiat_amount": 117.8, "crypto_amount": 10, "rate_applied": 11.78, "completed_at": "2026-08-10T06:52:30Z" } } ``` ## Cancel an order [#cancel-an-order] Cancel before crypto is sent: ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/orders/RAMP-20260810-6664a946/cancel \ -H "Authorization: Bearer $SECRET_KEY" ``` Cancellation is only possible when the order is in `crypto_pending` status. Once crypto is confirmed, the order cannot be cancelled. ## Automatic transaction matching [#automatic-transaction-matching] When you call `/confirm-crypto` without a `tx_hash`, the API searches the Stellar network for a matching USDC payment: * From the `source_wallet` you provide * To the merchant's Stellar address * For the exact expected USDC amount (with ±0.01 tolerance) * Created after the order was placed If a match is found, the transaction hash is returned and the order proceeds. If no match is found, you'll receive an error with details about what was expected. ## Error codes [#error-codes] | Error | Meaning | | -------------------------- | ------------------------------------------------------- | | `KYC_REQUIRED` | Phone number not verified | | `RAMP_NOT_ACTIVE` | Merchant ramp wallets not activated | | `RATE_UNAVAILABLE` | Exchange rate temporarily unavailable | | `INSUFFICIENT_LIQUIDITY` | Not enough GHS for the payout | | `LIMIT_ERROR` | Amount exceeds limits | | `RATE_EXPIRED` | Quote expired — create a new order | | `INVALID_STATUS` | Order not in the correct state for this operation | | `CRYPTO_VALIDATION_FAILED` | No matching USDC payment found on-chain | | `DUPLICATE_TX` | This transaction hash was already used by another order | ## Best practices [#best-practices] * Display the `merchant_stellar_address` and exact `crypto_amount` clearly so the user sends the correct amount * Call `/confirm-crypto` only after you're confident the user has sent the USDC (e.g., after they confirm in your UI or after a delay) * If the user provides the tx\_hash from their wallet, include it in the confirm request for faster validation * Use `idempotency_key` to safely retry order creation * Poll order status to detect when the payout completes * Handle `expired` status by creating a new order if the user missed the window # On-Ramp (Buy USDC) (/docs/ramp/onramp) The on-ramp flow lets users buy USDC with Ghana Cedis via Mobile Money. Your app creates an order, the user approves a MoMo prompt on their phone, and USDC is automatically sent to their Stellar wallet. ## Prerequisites [#prerequisites] * User must have completed [KYC verification](/docs/ramp/kyc) * Merchant must have an active USDC wallet (set up during activation) * Merchant must have sufficient USDC liquidity to fulfil the order ## Get a rate preview [#get-a-rate-preview] Before committing to an order, you can preview the current exchange rate without any side effects. ```bash curl https://api.your-domain.com/api/v1/ramp/quote \ -H "Authorization: Bearer $SECRET_KEY" \ -G \ -d "type=onramp" \ -d "fiat_amount=100" ``` **Response:** ```json { "success": true, "data": { "type": "onramp", "you_pay": "100.00 GHS", "you_receive": "8.21 USDC", "rate": "1 USDC = 12.18 GHS", "fiat_amount": 100, "fiat_currency": "GHS", "crypto_amount": 8.21, "crypto_currency": "USDC", "rate_applied": 12.18, "fee_amount": 1.5, "fee_bearer": "customer", "net_fiat_amount": 100, "net_crypto_amount": 8.21, "expires_in_seconds": 300 } } ``` You can specify either `fiat_amount` (GHS the user wants to pay) or `crypto_amount` (USDC the user wants to receive). The API calculates the other side. ## Create an on-ramp order [#create-an-on-ramp-order] This is the all-in-one endpoint that locks the rate, reserves liquidity, and initiates the Mobile Money collection in a single call. ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/orders \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "onramp", "fiat_amount": 100, "destination_wallet": "GBXYZ...USER_STELLAR_ADDRESS", "payment_method": "mobile_money", "idempotency_key": "order_abc_123", "user": { "first_name": "Kofi", "last_name": "Mensah", "phone": "0241234567", "network": "MTN" } }' ``` ### Request body [#request-body] | Field | Type | Required | Description | | -------------------- | ------ | -------- | ------------------------------------------------------------- | | `type` | string | Yes | Must be `"onramp"` | | `fiat_amount` | number | One of | Amount in GHS the user pays (specify this OR `crypto_amount`) | | `crypto_amount` | number | One of | Amount in USDC the user receives | | `destination_wallet` | string | Yes | User's Stellar address to receive USDC | | `payment_method` | string | Yes | `"mobile_money"` or `"bank_transfer"` | | `idempotency_key` | string | No | Unique key to prevent duplicate orders | | `user.first_name` | string | Yes | User's first name (must match KYC) | | `user.last_name` | string | Yes | User's last name | | `user.phone` | string | Yes | User's phone number (must be KYC verified) | | `user.network` | string | Yes | MoMo network: `MTN`, `VODAFONE`, `TELECEL`, or `AIRTELTIGO` | ### Response [#response] ```json { "success": true, "data": { "reference": "RAMP-20260810-a1b2c3d4", "order_id": "f16890cc-8fd0-4af7-982e-2100bf3dda14", "type": "onramp", "status": "fiat_pending", "you_pay": "100.00 GHS", "you_receive": "8.21 USDC", "rate": "1 USDC = 12.18 GHS", "fiat_amount": 100, "fiat_currency": "GHS", "crypto_amount": 8.21, "crypto_currency": "USDC", "rate_applied": 12.18, "fee_amount": 1.5, "fee_bearer": "customer", "net_fiat_amount": 100, "net_crypto_amount": 8.21, "expires_at": "2026-08-10T06:22:00Z", "message": "Payment initiated. Approve the mobile money prompt on your phone." } } ``` The order is immediately in `fiat_pending` status. A Mobile Money prompt is sent to the user's phone. The user must approve it before the rate expires. ## What happens after order creation [#what-happens-after-order-creation] 1. **MoMo prompt sent** — The user receives a payment prompt on their phone 2. **User approves** — Status transitions to `fiat_confirmed` 3. **USDC sent** — Status moves to `crypto_sending`, then `completed` with a `crypto_tx_hash` 4. **Rate expires** — If the user doesn't approve within the TTL, status becomes `expired` ## Check order status [#check-order-status] Poll the order to track progress: ```bash curl https://api.your-domain.com/api/v1/ramp/orders/RAMP-20260810-a1b2c3d4 \ -H "Authorization: Bearer $SECRET_KEY" ``` **Completed response includes the Stellar transaction hash:** ```json { "success": true, "data": { "reference": "RAMP-20260810-a1b2c3d4", "type": "onramp", "status": "completed", "crypto_tx_hash": "ab517c09c4b67f8c3573621a40cb6ad00887f16fe6264f6388267bb33b76ad51", "fiat_amount": 100, "crypto_amount": 8.21, "rate_applied": 12.18, "completed_at": "2026-08-10T06:25:57Z" } } ``` ## List orders [#list-orders] Retrieve paginated order history: ```bash curl "https://api.your-domain.com/api/v1/ramp/orders?type=onramp&limit=20&offset=0" \ -H "Authorization: Bearer $SECRET_KEY" ``` ## Cancel an order [#cancel-an-order] Cancel an order that hasn't been paid yet: ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/orders/RAMP-20260810-a1b2c3d4/cancel \ -H "Authorization: Bearer $SECRET_KEY" ``` Cancellation is only possible when the order is in `quote_locked` status (two-step flow). Orders created via the single-step `POST /orders` endpoint go directly to `fiat_pending` and cannot be cancelled once the MoMo prompt is sent. ## Two-step flow (legacy) [#two-step-flow-legacy] For more control, you can separate quote creation from payment initiation: ### Step 1: Create a quote [#step-1-create-a-quote] ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/quote \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "onramp", "fiat_amount": 100, "destination_wallet": "GBXYZ...USER_STELLAR_ADDRESS", "user": { "first_name": "Kofi", "last_name": "Mensah", "phone": "0241234567", "provider_name": "MTN" } }' ``` The order is created in `quote_locked` status with a locked rate. ### Step 2: Initiate payment [#step-2-initiate-payment] ```bash curl -X POST https://api.your-domain.com/api/v1/ramp/RAMP-20260810-a1b2c3d4/pay \ -H "Authorization: Bearer $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"channel": "mobile_money", "phone": "0241234567", "network": "MTN"}' ``` This sends the MoMo prompt and transitions the order to `fiat_pending`. ## Idempotency [#idempotency] Include an `idempotency_key` in your create order request to safely retry failed requests. If a request with the same key has already created an order, the existing order is returned without creating a duplicate. ## Error codes [#error-codes] | Error | Meaning | | ---------------------------- | ---------------------------------------------------- | | `KYC_REQUIRED` | Phone number not verified — complete KYC first | | `RAMP_NOT_ACTIVE` | Merchant ramp wallets not activated | | `RATE_UNAVAILABLE` | Exchange rate service temporarily unavailable | | `INVALID_DESTINATION_WALLET` | Stellar address is invalid or missing USDC trustline | | `INSUFFICIENT_LIQUIDITY` | Not enough USDC in merchant's liquidity pool | | `LIMIT_ERROR` | Amount exceeds transaction, daily, or monthly limits | | `RATE_EXPIRED` | Quote TTL elapsed — create a new order | | `COLLECTION_FAILED` | MoMo collection could not be initiated | ## Best practices [#best-practices] * Always validate the destination Stellar wallet has a USDC trustline before creating an order * Use `idempotency_key` for all order creation to handle network retries safely * Poll order status or set up webhooks to detect completion — do not assume success after creation * Show the user the rate and amount before they approve the MoMo prompt * Handle `expired` status gracefully by prompting the user to retry # Webhooks & Status Polling (/docs/ramp/webhooks) Ramp orders are processed asynchronously. After creating an order, you need to track its progress to know when it's completed, failed, or expired. ## Polling order status [#polling-order-status] The simplest integration is polling the order endpoint: ```bash curl https://api.your-domain.com/api/v1/ramp/orders/{reference} \ -H "Authorization: Bearer $SECRET_KEY" ``` ### Recommended polling strategy [#recommended-polling-strategy] | Order status | Poll interval | Action | | ------------------------------------- | ---------------- | ----------------------------------- | | `fiat_pending` | Every 5 seconds | User is approving MoMo prompt | | `crypto_pending` | Every 10 seconds | Waiting for user to send USDC | | `fiat_confirmed` / `crypto_confirmed` | Every 5 seconds | Processing — settlement in progress | | `crypto_sending` / `fiat_sending` | Every 5 seconds | Almost done | | `completed` / `failed` / `expired` | Stop polling | Terminal state reached | ### Example polling implementation [#example-polling-implementation] ```javascript async function pollOrder(reference, secretKey) { const baseUrl = 'https://api.your-domain.com/api/v1/ramp/orders'; const terminalStates = ['completed', 'failed', 'expired', 'cancelled']; while (true) { const res = await fetch(`${baseUrl}/${reference}`, { headers: { 'Authorization': `Bearer ${secretKey}` } }); const { data } = await res.json(); console.log(`Order ${reference}: ${data.status}`); if (terminalStates.includes(data.status)) { return data; } // Wait before next poll await new Promise(r => setTimeout(r, 5000)); } } ``` ## Merchant webhooks [#merchant-webhooks] If your merchant account has a webhook URL configured, payment events will be forwarded to your endpoint when the underlying fiat transaction completes. ### Webhook payload [#webhook-payload] ```json { "event": "payment.completed", "data": { "reference": "PAY-20260810-3e848662-699d-41e8-8d90-fa25cecdb8ac", "status": "completed", "amount": 1015, "currency": "GHS", "provider_ref": "41e88d90fa25cecdb8ac", "channel": "mobile_money", "meta": { "ramp_order_id": "f16890cc-8fd0-4af7-982e-2100bf3dda14", "ramp_type": "onramp" } } } ``` The `meta.ramp_order_id` and `meta.ramp_type` fields identify which ramp order the payment belongs to. Use this to match webhook events back to your orders. ### Webhook events for ramp [#webhook-events-for-ramp] | Event | When | Ramp action | | ------------------- | ----------------------------------------------------- | ------------------------------------------------------------ | | `payment.completed` | Fiat collected (on-ramp) or fiat disbursed (off-ramp) | USDC send initiated (on-ramp) or order completing (off-ramp) | | `payment.failed` | Fiat collection or payout failed | Order may retry or fail | Webhooks confirm the fiat leg only. For the complete order lifecycle (including crypto send confirmation), poll the order status endpoint. ## Order status transitions [#order-status-transitions] ### On-ramp lifecycle [#on-ramp-lifecycle] ``` quote_locked → fiat_pending → fiat_confirmed → crypto_sending → completed │ │ ├──→ expired └──→ failed └──→ cancelled ``` ### Off-ramp lifecycle [#off-ramp-lifecycle] ``` crypto_pending → crypto_confirmed → fiat_sending → completed │ │ ├──→ expired └──→ fiat_retry → fiat_sending └──→ cancelled └──→ failed ``` ## Response fields by status [#response-fields-by-status] ### Terminal: `completed` [#terminal-completed] ```json { "status": "completed", "crypto_tx_hash": "ab517c09c4b67f8c...", "completed_at": "2026-08-10T06:25:57Z" } ``` For on-ramp, `crypto_tx_hash` is the Stellar transaction where USDC was sent to the user. For off-ramp, `crypto_tx_hash` is the Stellar transaction where the user sent USDC. ### Terminal: `failed` [#terminal-failed] ```json { "status": "failed", "failure_reason": "Crypto send failed after max retries" } ``` Always check `failure_reason` for details on what went wrong. ### Terminal: `expired` [#terminal-expired] ```json { "status": "expired", "expires_at": "2026-08-10T06:22:00Z" } ``` The rate TTL elapsed before payment was confirmed. Create a new order. ## Handling late payments [#handling-late-payments] If a user's MoMo payment confirms after the rate expires: * The system automatically detects the late payment * A refund is initiated back to the user's Mobile Money * The order status moves to `fiat_refunded` For off-ramp, if USDC arrives after expiry: * The system detects the late crypto payment * USDC is automatically refunded to the user's source wallet * The order status moves to `crypto_refunded` ## Best practices [#best-practices] * Always treat `completed` as the only success state — don't fulfil based on intermediate states * Implement exponential backoff if you encounter rate limits during polling * Log the full order response for debugging — especially `failure_reason` on failed orders * For production, combine polling with webhooks for the fastest detection * Set a maximum poll duration (e.g., 30 minutes) and handle timeout gracefully * Verify `crypto_tx_hash` on the Stellar network if your application requires proof of settlement # Settings (/docs/settings) Settings holds both organization-owned information and your personal Seev account details. Before changing anything, work out whether the change should affect the whole business workspace or only your own login, because switching organizations changes the first kind and never the second. ## Find the right settings page [#find-the-right-settings-page] Settings groups everything under Workspace settings. Each page needs its own permission, so a page you cannot see is one your role does not grant rather than one that does not exist. | Page | Belongs to | Needs | | --------------------- | ----------------------- | ------------------------------------------------------------- | | **Business Identity** | The active organization | `organization.verification.manage` | | **Business profile** | The active organization | `organization.verification.manage` | | **Alerts** | The active organization | `organization.alerts.manage` | | **Payouts** | The active organization | `payout_settings.read` or `payout_settings.manage` | | **Personal profile** | Your login | Nothing. Every member sees it. | | **Team permissions** | The active organization | Any of `team.read`, `team.manage`, `role.read`, `role.manage` | Owners and admins hold every permission. See [Team Access](/docs/settings/team) for granting a member less than that. ## Change the name customers see [#change-the-name-customers-see] Business Identity controls the name shown at checkout, on invoices, on payment links, on storefronts, on receipts, and in payment notifications. The page shows the verified legal name, the name currently shown to customers, the identity verification status, and the review status of any name change. Enter a name between 2 and 120 characters. Anything shorter or longer is refused with "Business name must contain between 2 and 120 characters." and nothing is submitted. The field shows a live character count against the 120 limit. A name change goes to review, not straight to your customers. The current name stays active on checkout and receipts until the new one is approved, and while a request is pending you cannot submit another. Get the name right before submitting rather than planning to correct it. Replacing a name that was already approved asks you to confirm first, since it puts the approved name back into review. On submission you get "Your name-change request was submitted for review". The review status reads Approved, Pending review, Changes required, or Not configured. Changes required means the request was rejected; read the reason on the page and submit a corrected name. ## Read the business profile [#read-the-business-profile] Business profile shows the KYB or KYC details held for this organization, drawn from its verification record. It is where you confirm what Seev actually holds rather than what you believe you submitted. Editing opens the verification flow with the existing information prefilled, so you update the existing record rather than starting a new verification type. If the verification was rejected, read the reason before changing anything. See [Complete Verification](/docs/organizations/verify). ## Route payment alerts [#route-payment-alerts] Alerts controls where Seev Plus emails a notification when the organization receives a successful payment. Turn the successful-payment alert on, enter the address that should receive it, and save. Saving with the alert enabled and no address shows "Enter an email address for payment alerts."; an address Seev cannot parse shows "Enter a valid email address." Neither submits anything. A failure at the server shows "Could not save alert settings" and leaves the previous setting in force, so check the toggle state after an error rather than assuming the change landed. A teammate holding only read access to this page sees a "View only" badge and disabled controls rather than an error. This alert address is not the address that receives the weekly transaction summary, which always goes to the organization owner. See [Accounts and Wallets](/docs/accounts). ## Set up payouts [#set-up-payouts] Payouts controls how often settled funds move and where they go. Manual payouts are no longer offered, so an organization collecting money needs a schedule and a destination. See [Payout Schedules](/docs/settings/payouts) before you enable one. ## Manage team access [#manage-team-access] Team permissions covers members, built-in roles, and custom roles built from permission keys. Give each person their own login and the least access that lets them work. See [Team Access](/docs/settings/team). ## Update your own profile [#update-your-own-profile] Personal profile changes your own account details, such as your name. These follow you between organizations and appear in greetings and account menus. They do not change any organization's verification record, and changing your personal name does not change the name customers see. Settings also carries the appearance choice between light, dark, and system. ## Turn on two-step sign-in [#turn-on-two-step-sign-in] Two-step sign-in asks for a code as well as your password each time you sign in, so a stolen password is not enough to get into your account. It is off until you turn it on, and it applies to your own sign-in, not to everyone in the organization. 1. Open **Settings**, then **Personal profile**. 2. Under **Two-step sign-in**, choose **Turn on**. 3. Pick where codes are sent: **Email**, which uses your account's address, or **Text message**, which uses a phone number you give. 4. Enter the 6-digit code we send. This confirms you can read messages at that address or number. From then on, signing in takes your password and then a code. A code lasts 10 minutes and stops working after five wrong tries; ask for a new one from the same screen. To turn it off, choose **Turn off** and enter a code sent the usual way. We email you whenever two-step sign-in is turned on or off, so a change you did not make does not go unnoticed. If you lose access to the phone or inbox your codes go to, you cannot sign in. Contact [support@seevcash.com](mailto:support@seevcash.com) to recover the account. ## If a change does not appear [#if-a-change-does-not-appear] Work through these before resubmitting anything. 1. Confirm the active organization in the sidebar selector. A change saved in one organization will not show in another. 2. Check whether the field is waiting on review rather than saved. A business name under review leaves the old name in place by design. 3. Check whether verification is locking the field. Details submitted for review cannot be edited while the review is active. 4. Reload once before assuming the save failed. Repeated submissions of the same change do not speed up a review and can leave duplicate requests behind. # Payout Schedules (/docs/settings/payouts) Payout settings belong to the active organization and control the automatic movement of settled GHS funds. Cedi funds move only on this schedule; the cedi balance has no send action. USD is separate, and you can push a USD transfer yourself with [Send money](/docs/payments/payouts). Open **Settings**, then **Payouts**. The header names the organization the settings apply to, so check it before changing anything. Manual payouts are no longer available. Settlements always follow a schedule, so an organization with settled funds and no saved destination has nowhere for those funds to go. If you previously ran payouts manually, add a destination and pick a schedule. ## Choose how often payouts run [#choose-how-often-payouts-run] The Schedule card offers three options. Select one, then save. | Dashboard label | Stored value | What the dashboard says it means | | ---------------------- | ------------ | ---------------------------------- | | **Every business day** | `daily` | Settled funds go out each weekday. | | **Weekly** | `weekly` | One payout a week. | | **Monthly** | `monthly` | One payout a month. | The option currently saved shows its next run date beside it, so you can confirm the change took effect without leaving the page. The daily schedule is computed every day, but money only moves when banks are open, so payouts land Monday to Friday. A payout that comes due over a weekend arrives on the next business day. ## Add a payout destination [#add-a-payout-destination] Select **Add account** on the Destination card, choose the destination type, fill in the details, then select **Add payout destination**. A saved destination appears as a row with an Active or Inactive chip. Four destination types are offered. | Destination | Sends to | | ---------------------- | ------------------------------------ | | **SeevCash** | A SeevCash username. | | **Ghana mobile money** | An MTN, Telecel, or AT Money wallet. | | **Ghana bank** | A Ghanaian bank account. | | **USD bank** | A USD ACH bank account. | SeevCash asks for one field. | Field | Type | Required | Description | | ----------------- | ---- | -------- | -------------------------------------------------------------------------- | | SeevCash username | Text | Yes | Entered in the form `@johndoe`. Seev prefixes the `@` if you leave it off. | Ghana mobile money asks for three fields, all required. | Field | Type | Required | Description | | ------------ | ----------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | Account name | Text | Yes | Must match the name registered on the mobile-money wallet, not the trading name of your business. | | Network | Choice of MTN, Telecel, or AT Money | Yes | Must be the network the number is actually registered on. A number ported to another network fails against the old one. | | Phone number | Text | Yes | The mobile-money number itself. | Ghana bank asks for four fields, one of which is genuinely optional. | Field | Type | Required | Description | | -------------- | ---- | -------- | ------------------------------------------------------------------------- | | Account name | Text | Yes | The name on the bank account. | | Bank name | Text | Yes | The receiving bank. | | Account number | Text | Yes | The account number at that bank. | | Bank code | Text | No | Include it when you have it, since it removes ambiguity between branches. | USD bank asks for the most, because ACH needs a full postal address. | Field | Type | Required | Description | | ------------------ | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | Account owner name | Text | Yes | The legal owner of the receiving account. | | Email | Email | Yes | Contact address for the account owner. | | Bank name | Text | Yes | The receiving bank. | | Routing number | Text | Yes | The ACH routing number, which is not always the same as the wire routing number printed on a statement. | | Account number | Text | Yes | The account number at that bank. | | Account type | Choice of Checking or Savings | Yes | Must match how the account is actually held, since ACH rejects a mismatch. | | Street address | Text | Yes | Account owner's street address. | | Address line 2 | Text | No | Apartment, suite, or similar. | | City | Text | Yes | Account owner's city. | | State | Text | Yes | Account owner's state. | | Postal code | Text | Yes | Account owner's postal code. | | Country | Text | Yes | Account owner's country. | Seev pays whatever account you save. A transposed account number or a mobile-money number registered to someone else sends money to the wrong person, and recovering it depends on the receiving institution rather than on Seev. Read the details back against a bank statement or the recipient's wallet before you save. **Add payout destination** stays disabled until every required field for the chosen type holds a value, so a greyed-out button means a blank field rather than a rejected account. When the save itself fails you get "Could not add payout destination"; nothing is stored, so correct the details and submit again rather than assuming a partial record exists. The Destination card is empty until you add one, and says so. An empty Destination card with a schedule selected means no payouts can run. ## Save the schedule [#save-the-schedule] **Save changes** stays disabled until both of these are true: 1. A destination is selected for the schedule. 2. Something on the page has actually changed since it last loaded. So a disabled button most often means you have not chosen a destination, or you have not changed anything yet. Saving confirms with "Payout schedule updated"; a failure shows "Could not update payout schedule" and leaves the previous schedule in force. If the page shows "Could not load payout destinations." or "Could not load payout schedule.", the settings did not load and you are not looking at your real configuration. Reload before changing anything. An organization that has never set a schedule is not an error state and loads as an empty schedule. The Payouts card in Settings is hidden from teammates who hold neither the `payout_settings.read` nor the `payout_settings.manage` permission. If a teammate cannot find Payouts, check their role in [Team Access](/docs/settings/team). ## Check when the next payout runs [#check-when-the-next-payout-runs] Three figures sit at the top of the Payouts page: the next payout with its destination, the amount paid out in the current month with a payout count, and the last payout. All are dashboard figures in major currency units. "Not scheduled" under Next payout means no schedule is in force. "None yet" under Last payout means no payout has ever completed for this organization. ## Review a completed payout [#review-a-completed-payout] The Payouts page lists the four most recent settlements under Recent payouts. For the full history, use the Activity feed on [Balances](/docs/accounts), which carries payouts alongside other money movement and has a Payouts filter. Select a payout to open its reconciliation view. The header records what was sent and under what terms. | Field | Description | | ----------------- | ------------------------------------------------------------------------------------ | | Amount paid | The net amount that left Seev, as a dashboard figure in major units. | | Currency | The currency of the payout. | | Status | Where the settlement stands. | | Payment reference | The reference to quote to your bank or to support. | | Settlement ID | Seev's identifier for the settlement. Older payouts predate it and cannot be opened. | | Schedule | The schedule in force when the payout ran, which may differ from the one saved now. | | Covered period | The window of transactions the payout drew from. | | Paid | The date it was sent. | | Destination | The account it went to. | | Note | Shown only when a note was recorded. | | Proof of payment | A document link, shown only when one was recorded. | Below the header, a reconciliation summary accounts for the difference between what you sold and what you were paid. | Line | Description | | ----------------- | ------------------------------------------- | | Gross sales | Total collected across the covered period. | | Processing fees | Fees taken by Seev on those payments. | | Refunds | Amounts returned to customers. | | Chargebacks | Amounts reversed by a card or bank dispute. | | Other adjustments | Anything not covered by the lines above. | | Net payout | What actually reached the destination. | The included transactions are then listed individually with their date, reference, customer, channel, status, gross, fee, and net, ending in a totals row. If the listed transactions do not add up to the summary, the view says so rather than hiding the difference. Treat a mismatch warning as a reconciliation problem to raise with support, not as a display glitch. Some payouts recorded before settlement IDs existed cannot be opened at all. Those rows are inert and explain on hover that transaction details are unavailable for that older payout. A payout with no recorded breakdown shows that no reconciliation summary was recorded rather than showing zeroes. If the detail fails to load you get "We could not load this payout" with a retry action. The payout itself still happened; only the breakdown is unavailable. ## Account for subaccount splits [#account-for-subaccount-splits] [Subaccounts](/docs/payments/subaccounts), under **Manage** in the sidebar, give a partner, branch, or vendor a percentage share of a payment. The share must be above 0 and below 100, fees come off the top before the split applies, and the partner's cut lands in a separate balance with its own payout record. Payouts on this page move only your own share, so a payout smaller than your gross sales may still be correct. A subaccount whose account holder name has not been confirmed cannot receive its share. Payments still succeed and the held share stays in your main balance, which raises your payout rather than lowering it. Pausing a subaccount has the same effect, and the dashboard says so before you confirm the pause. A split applies to [API payments that pass the subaccount in](/docs/payments/subaccounts#split-a-payment-through-the-api). Invoices, payment links, storefront and POS charges created from the dashboard are not split, so their full amount stays in your own balance. ## Troubleshooting [#troubleshooting] ### Why is Save changes greyed out? [#why-is-save-changes-greyed-out] Either no destination is selected, or nothing has changed yet. Both conditions must clear before the button enables. ### Why has no payout run despite a saved schedule? [#why-has-no-payout-run-despite-a-saved-schedule] Check that the destination row shows an Active chip and that there were settled funds in the covered period. Also confirm the schedule saved: the option in force shows its next run date beside it, and the hero strip reads "Not scheduled" when nothing is in force. ### Why can a teammate not see Payouts in Settings? [#why-can-a-teammate-not-see-payouts-in-settings] Their role grants neither `payout_settings.read` nor `payout_settings.manage`. ### Why does the payout amount not match my sales for the period? [#why-does-the-payout-amount-not-match-my-sales-for-the-period] Processing fees, refunds, chargebacks, other adjustments, and any subaccount splits all sit between gross sales and the net payout. Open the payout and read its reconciliation summary line by line. ## Related guides [#related-guides] * [Send Money](/docs/payments/payouts) * [Balances and Activity](/docs/accounts) * [Organization Verification](/docs/organizations/verification) * [Team Access](/docs/settings/team) # Team Access (/docs/settings/team) Team access lets several people work in one organization using their own Seev logins. The organization keeps its records when a member leaves, so removing someone costs you nothing but their access. ## Choose a role [#choose-a-role] Every membership carries one of three built-in roles. They nest: an owner satisfies anything an admin can do, and an admin satisfies anything a member can do. | Role | Suited to | Access | | ---------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | **Owner** | The business owner or primary account holder | Every permission in the organization. | | **Admin** | A trusted operations or finance lead | Every permission in the organization. | | **Member** | Everyone else | Only the permissions granted by an assigned custom role, plus the three permissions every member always holds. | Owner and admin both resolve to every permission, including moving money, revealing card details, managing API keys, and closing the organization. Admin is not a reduced form of owner. If you want a teammate to have less than total access, make them a member and give them a custom role. Every active member, whatever their role, always holds `dashboard.read`, `organization.read`, and `role.read`. These cannot be taken away or stored on a custom role. ## Grant only what someone needs [#grant-only-what-someone-needs] A custom role is a named set of permission keys. Assigning one makes the person a member and attaches the role, so their access is exactly the always-allowed three plus the keys on that role. Permissions are grouped by the area they govern. Each is also classified by risk, so the ones that move money or expose secrets are marked as such where you assign them. | Area | Permission keys | | ------------------------- | --------------------------------------------------------------------------------------------------- | | Reporting | `analytics.read` | | Organization profile | `organization.update`, `organization.alerts.manage` | | Organization verification | `organization.verification.manage` | | Organization security | `organization.security.manage`, `organization.close` | | Team and roles | `team.read`, `team.manage`, `role.manage` | | Wallets | `wallet.read`, `wallet.manage`, `wallet.deposit`, `wallet.withdraw`, `wallet.transfer` | | Bank transfers | `bank_transfer.read`, `bank_transfer.create` | | Cards | `card.read`, `card.create`, `card.manage`, `card.fund`, `card.details.reveal` | | Yield | `yield.read`, `yield.manage`, `yield.deposit`, `yield.withdraw` | | Customers | `customer.read`, `customer.manage` | | Invoices | `invoice.read`, `invoice.create`, `invoice.update`, `invoice.collect` | | Payment links | `payment_link.read`, `payment_link.create`, `payment_link.update`, `payment_link.publish` | | Stores and products | `store.read`, `store.manage`, `product.manage` | | Orders | `order.read`, `order.manage`, `order.pos.create` | | Developer access | `developer.read`, `developer.settings.manage`, `developer.keys.manage`, `developer.webhooks.manage` | | Payouts | `payout_settings.read`, `payout_settings.manage` | A few of these have consequences worth knowing before you grant them: * `organization.security.manage` allows setting and changing the organization's [transaction PIN](/docs/accounts/pin), which gates every sensitive money action. * `card.details.reveal` exposes full card numbers and CVVs. See [Cards](/docs/accounts/cards). * `developer.keys.manage` allows creating and rotating API keys, which act on the organization without any PIN prompt. * `organization.close` allows starting closure of the whole organization. ## Add or update a teammate [#add-or-update-a-teammate] 1. Confirm the correct organization in the sidebar selector. 2. Open **Settings**, then **Team**. 3. Add the teammate using their Seev identity or the invitation flow. 4. Choose a built-in role, or make them a member and assign a custom role. 5. Review the resulting access before confirming. Role changes apply only to the selected organization. The same person can hold different roles in different organizations. Managing team members needs `team.manage`, and creating or editing custom roles needs `role.manage`. Seeing the team at all needs `team.read`. If Team is missing from Settings, or the controls on it are unavailable, the account lacks the relevant key rather than having hit a bug. An invitation sits as pending until the person accepts it, and can be revoked before then. A membership itself is either active or disabled; only an active membership grants any access at all, so a disabled member is denied even the permissions their role lists. ## Understand what a blocked teammate is seeing [#understand-what-a-blocked-teammate-is-seeing] Permission failures in the dashboard show up in two different ways, and telling them apart saves a lot of guessing. * **A missing sidebar item or hidden card.** The navigation filters on the read permission for each area, so Balances needs `wallet.read`, Customers needs `customer.read`, Subaccounts and the Settings payouts card need `payout_settings.read` or `payout_settings.manage`, and the Developers group needs `developer.read`. A group with no visible items disappears entirely. * **A refused request.** An action the person can see but not perform is rejected by the server with an insufficient-permissions error. Two sidebar items are hidden for reasons other than permissions: Cards appears only for organizations verified as a business, and Yield stays hidden until a yield account exists. See [Cards](/docs/accounts/cards) and [Yield](/docs/accounts/yield). ## When someone leaves [#when-someone-leaves] Remove their membership promptly. Their personal Seev login stays theirs; the organization keeps its stores, invoices, customers, balances, and reports. Removing a membership does not rotate anything they knew. If they had the transaction PIN or an API key, change the PIN and rotate the key as well. ## Credentials that are never shared [#credentials-that-are-never-shared] * Login passwords * Transaction PINs * API secret keys * Webhook signing secrets * Full card details * Verification documents outside the upload flow Giving someone their own membership and a custom role is always the right answer to "they need to do X". Sharing a credential is not. ## Troubleshooting [#troubleshooting] ### Why can a member see a page but not act on it? [#why-can-a-member-see-a-page-but-not-act-on-it] Their custom role grants the read key for that area without the corresponding manage, create, or update key. ### Why does an admin still have access I meant to restrict? [#why-does-an-admin-still-have-access-i-meant-to-restrict] Admin resolves to every permission. Restricting access means making the person a member with a custom role. ### Why is a teammate denied everything despite having a role? [#why-is-a-teammate-denied-everything-despite-having-a-role] Check that their membership status is active. A disabled membership is refused regardless of role. ### Can one person belong to several organizations? [#can-one-person-belong-to-several-organizations] Yes, with a different role in each. See [Organizations](/docs/organizations). # Go SDK (/docs/sdks/go) The official Seev Go SDK is not currently published for production use. Use the REST API from your backend for production checkout integrations today. Keep your secret key server-side, create checkout sessions with the API, and verify payment results before fulfilment. ## Use this approach today [#use-this-approach-today] * [Create checkout sessions with the REST API](/docs/payments/checkout) * [Verify payments server-side](/docs/payments/verify) * [Handle signed webhooks](/docs/developer/webhooks) * [Use idempotency for safe retries](/docs/idempotency) # SDKs & Integrations (/docs/sdks) Seev SDKs, browser packages, mobile SDKs, and platform integrations are not currently published for production use. This includes: * Node.js / TypeScript SDKs * Go, Python, PHP, Rust, and other server libraries * Browser, React, Vue, and React Native packages * WordPress, WooCommerce, Shopify, and other platform plugins For production integrations today, use the REST API from your backend. Checkout sessions should be created on a trusted server, and payment results should be verified server-side before fulfilment. ## Supported approach today [#supported-approach-today] 1. Your frontend sends the cart or order ID to your backend. 2. Your backend validates the order and creates a checkout session with the REST API. 3. Seev returns a hosted checkout URL. 4. Your frontend redirects the customer to that URL. 5. Your backend verifies the result and processes signed webhooks. ## Next steps [#next-steps] * [Create a checkout session with the REST API](/docs/payments/checkout) * [Verify payments server-side](/docs/payments/verify) * [Handle signed webhooks](/docs/developer/webhooks) * [Use idempotency for safe retries](/docs/idempotency) # Browser JavaScript (/docs/sdks/js) A public browser SDK is not currently available. Browser JavaScript must not create checkout with a secret key. ## Use this pattern [#use-this-pattern] 1. Your browser calls your own backend. 2. Your backend creates a Seev checkout session. 3. Your backend returns the checkout URL. 4. The browser navigates to that URL. 5. Your backend verifies the result and handles signed webhooks. Use [Checkout API](/docs/payments/checkout) for the server request. If you do not need custom code, create a [Payment Link](/docs/payments/links) in Seev Plus instead. ## What the browser may receive [#what-the-browser-may-receive] Return only safe values such as the checkout URL, reference, expiry, and your own public order ID. Keep the secret key and full verification logic on the server. While checkout is being created, disable the pay button to avoid duplicate submissions. If your server times out, retry with the same idempotency key instead of creating a new logical order. # Node.js / TypeScript SDK (/docs/sdks/node) The official Seev Node.js / TypeScript SDK is not currently published for production use. Use the REST API from your backend for production checkout integrations today. Keep your secret key server-side, create checkout sessions with the API, and verify payment results before fulfilment. ## Use this approach today [#use-this-approach-today] * [Create checkout sessions with the REST API](/docs/payments/checkout) * [Verify payments server-side](/docs/payments/verify) * [Handle signed webhooks](/docs/developer/webhooks) * [Use idempotency for safe retries](/docs/idempotency) # PHP Integration (/docs/sdks/php) An official PHP or Composer package is not currently published. ## Supported approach today [#supported-approach-today] Call the [Checkout API](/docs/payments/checkout) from Laravel, Symfony, or your PHP backend using its HTTP client. Keep the secret in server configuration, create checkout on the server, send the customer to the returned checkout URL, verify the result, and process [signed webhooks](/docs/developer/webhooks). Do not install an unofficial package as a shortcut without reviewing its security and ownership. Never expose a Seev secret in rendered HTML or frontend JavaScript. Use the current REST request and response examples in [Checkout API](/docs/payments/checkout). ## Framework placement [#framework-placement] | Framework | Put checkout creation in | | --------- | --------------------------------------------------------------- | | Laravel | A controller that calls a dedicated payment service. | | Symfony | A controller or message handler backed by a payment service. | | Plain PHP | A protected server endpoint, never a browser-generated request. | Save your internal order before redirecting, record the Seev reference, and use a signed webhook to update the order if the customer never returns. # Python Integration (/docs/sdks/python) An official Python SDK is not currently published. Do not install packages claiming to be an official Seev Python SDK unless Seev announces them here. ## Supported approach today [#supported-approach-today] Call the [Checkout API](/docs/payments/checkout) from your Django, Flask, or FastAPI backend using your preferred HTTP client. 1. Keep the secret key in a server environment variable. 2. Create checkout from a backend route. 3. Return only the checkout URL and safe reference to your frontend. 4. Verify the payment on your server. 5. Handle [signed webhooks](/docs/developer/webhooks). Never place the secret key in browser JavaScript, a mobile app, or a public repository. Use the REST examples in [Checkout API](/docs/payments/checkout) until the official Python SDK is released. ## Framework placement [#framework-placement] | Framework | Put checkout creation in | | --------- | ---------------------------------------------------------- | | Django | An authenticated view or service called by the order view. | | Flask | A server route backed by your order service. | | FastAPI | A server endpoint with a typed request model. | Use a sensible HTTP timeout, log the Seev reference with your order ID, and retry temporary failures with the same idempotency key. # React Native and Expo (/docs/sdks/react-native) A native React Native SDK is not currently published. 1. The app requests checkout from your backend. 2. The backend creates the session with a protected secret. 3. The app opens the returned checkout URL with the system browser or an appropriate secure WebView. 4. Your backend verifies payment and handles webhooks. Never bundle the Seev secret in the application. Mobile binaries and over-the-air bundles can be inspected. See [Checkout API](/docs/payments/checkout) for the backend flow. ## Returning to the app [#returning-to-the-app] Configure a deep link or universal link only for navigation. After the app opens, ask your backend for the verified order status. Do not trust a success value supplied only by the client-side link. If the customer closes checkout, keep the order pending until your backend receives a final verification result or signed webhook event. # React and Next.js (/docs/sdks/react) A dedicated React component package is not currently published. React should request checkout from a trusted backend. For Next.js, create the session in a Route Handler or Server Action that reads the secret from server-only environment variables. Return the checkout URL to the client and navigate to it. For a separate React SPA, call your own backend API. Never put the Seev secret in a `VITE_`, `NEXT_PUBLIC_`, or other client-exposed variable. Continue with [Checkout API](/docs/payments/checkout) and [Verify Payments](/docs/payments/verify). ## Recommended component flow [#recommended-component-flow] 1. Disable the checkout button while your server request is pending. 2. Send an order or cart identifier to your backend. 3. Let the backend calculate or validate the amount and create checkout. 4. Navigate to the returned hosted checkout URL. 5. Show a verifying state after the customer returns. 6. Read final payment status from your backend, not from URL parameters alone. Use a signed webhook to update the underlying order even if the customer closes the browser before returning. # Rust Integration (/docs/sdks/rust) An official Seev Rust crate is not currently published. Use your server HTTP client to call the [Checkout API](/docs/payments/checkout). Store the secret outside source control, create sessions on the server, verify payment results, and validate [webhook signatures](/docs/developer/webhooks#webhook-signing) against the raw request body. Do not expose API secrets in desktop, WebAssembly, browser, or mobile binaries. ## Production checklist [#production-checklist] * Deserialize success and error responses into separate typed structures. * Set connection and request timeouts. * Store your order ID and Seev reference together. * Reuse the same idempotency key when retrying a create request. * Verify webhook signatures in constant time before deserializing the raw body. * Keep the order pending when the final payment result is unknown. # Vue and Nuxt (/docs/sdks/vue) A dedicated Vue package is not currently published. Create checkout in a Nuxt server route or your own backend, keep the secret server-only, and return the checkout URL to the Vue application. The browser can then open that URL. Do not put secret keys in `VITE_`, `NUXT_PUBLIC_`, or other public runtime configuration. Continue with [Checkout API](/docs/payments/checkout) and [Webhook Signing](/docs/developer/webhooks#webhook-signing). ## Recommended Nuxt flow [#recommended-nuxt-flow] 1. Submit the cart to a Nuxt server route. 2. Validate price and customer details on the server. 3. Create checkout with a server-only key and stable idempotency key. 4. Return the checkout URL to the page. 5. Navigate the customer to hosted checkout. 6. Verify the result and process signed webhooks on the server. Keep a loading state visible until navigation starts so customers do not submit twice. # Hosted Checkout Integration (/docs/sdks/payments) Seev currently provides a hosted checkout URL rather than a published browser, React, Vue, or native payment component. The same pattern works for websites, mobile apps, and desktop applications: 1. Your frontend asks your backend to start payment. 2. Your backend creates a checkout session using a protected secret key. 3. Seev returns a checkout URL and reference. 4. Your frontend opens the checkout URL. 5. Your backend verifies the result and handles signed webhooks. ## Why checkout starts on the server [#why-checkout-starts-on-the-server] The secret key authorizes payment creation. Putting it in browser or mobile code would allow anyone to extract and misuse it. The server should also calculate or validate the amount, currency, items, and internal order reference rather than trusting values sent by the browser. ## Choose your server integration [#choose-your-server-integration] ## Frontend behavior [#frontend-behavior] While the backend creates checkout, disable the payment button and show a preparing-payment state. When the URL returns, navigate to it. If creation fails, keep the cart or form intact and show a retryable message. After the customer returns, show a verifying-payment state until your server confirms the result. Do not show success based only on a redirect query parameter. ## Mobile applications [#mobile-applications] Open the checkout URL in the system browser or a suitable secure WebView. Keep all secret-key and verification logic on your backend. ## Production checklist [#production-checklist] * Checkout is created only on the server. * Amounts are calculated or validated server-side. * Sandbox and production keys are separate. * The customer sees a loading state during creation and verification. * Your server verifies payment before fulfilment. * Signed webhooks are checked using the raw request body. * Your fulfilment logic is idempotent. ## Next steps [#next-steps] * [Create a checkout session](/docs/payments/checkout) * [Verify payments](/docs/payments/verify) * [Handle signed webhooks](/docs/developer/webhooks) * [Understand errors](/docs/errors)