# Soca — Developer Integration Guide Non-custodial USDC payments and subscriptions on Solana. You create a payable object (an invoice or a subscription plan) from your backend, then send your customer to a hosted, non-custodial checkout URL we return. Money settles directly to the merchant's own Solana wallet — we never custody funds — and customers pay no network fees (gas is sponsored). Mental model: this is closer to Stripe Payment Links than to a card API. You do NOT build wallet or card UI. You create an object, link the customer to its `checkout_url`, and fulfill via a signed webhook. Human-readable docs: https://soca.finance/docs (markdown mirror of every page is linked from https://soca.finance/llms.txt). -------------------------------------------------------------------------------- ## Core concepts - Base URL: https://soca.finance - Currency: USDC on Solana, always in BASE UNITS (6 decimals). $9.00 = 9000000. In JS: `Math.round(dollars * 1e6)`. - Field names are snake_case in both requests and responses; amounts (base units) are returned as strings. - Non-custodial: funds settle to the merchant's on-file wallet. The settlement wallet cannot be set via the API (changing it needs a fresh signature). - No gas for customers: transactions are sponsored. - Test vs live: determined by your API key prefix — `sk_test_...` or `sk_live_...`. Everything a key creates and reads is scoped to that key's merchant and mode. -------------------------------------------------------------------------------- ## Authentication Send your secret key as a Bearer token on every API request: Authorization: Bearer sk_live_YOUR_SECRET_KEY Keep the secret key SERVER-SIDE only. Create and revoke keys in Dashboard -> Developers. Missing/invalid/revoked keys return 401. -------------------------------------------------------------------------------- ## The official SDK — @usesoca/sdk (TypeScript) npm i @usesoca/sdk import { Soca, verifyWebhookSignature } from '@usesoca/sdk'; const soca = new Soca(process.env.SOCA_SECRET_KEY); const invoice = await soca.invoices.create({ amount: 2500000, description: 'Acme Pro — March' }); // -> invoice.checkout_url Namespaces map 1:1 to the REST endpoints below: `soca.invoices.create/list`, `soca.plans.create` (fixed or usage_type:'metered'), `soca.meters.create/list`, `soca.features.create`, `soca.payments.list`, `soca.subscriptions.list`, `soca.events.list`, `soca.meterEvents.create/createBatch`, `soca.credits.grant/debit/balance`, `soca.entitlements.grant/check`. Errors throw a typed `SocaError` with `.status` and `.code`. Webhooks verify with `verifyWebhookSignature(secret, rawBody, signatureHeader)`. -------------------------------------------------------------------------------- ## Create a one-time invoice — POST /v1/invoices Body (JSON): - amount (required) integer, USDC base units (e.g. 2500000 = 2.50 USDC) - description (required) string, shown on the checkout page - memo (optional) string - invoice_number (optional) string, your own reference - recipient (optional) string, who it's billed to (label/email) - due_at (optional) ISO 8601 date string Example: curl -X POST https://soca.finance/v1/invoices \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"amount":"2500000","description":"Acme Pro — March","invoice_number":"INV-1041"}' Response 201: { "object": "invoice", "id": "invoice_...", "amount": "2500000", "status": "open", "checkout_url": "https://soca.finance/invoice/invoice_..." } Send the customer to `checkout_url`. No money moves until they pay. When they do, an `invoice.paid` webhook fires. -------------------------------------------------------------------------------- ## Create a subscription plan — POST /v1/plans Body (JSON): - name (required) string, the plan/product name (2–80 chars) - amount (required) integer, USDC base units per period (min 1000000 = 1.00 USDC). For a metered plan this is the per-period CAP. - interval (required) one of: week, biweekly, month, year, custom - interval_days (required only when interval = custom) integer 1–365 - description (optional) string - usage_type (optional) "licensed" (default, fixed charge) or "metered" (bill usage each period up to the cap) - meter (required when usage_type = metered) the meter id (from POST /v1/meters) whose usage is billed - pricing_model (required when usage_type = metered) how usage is priced, e.g. {"kind":"per_unit","unitAmount":100}; also package/tiered/flat Example (fixed): curl -X POST https://soca.finance/v1/plans \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"name":"Pro","amount":"9000000","interval":"month"}' Example (metered — auto-charges usage each period, capped at amount): curl -X POST https://soca.finance/v1/plans \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"name":"API","amount":"100000000","interval":"month","usage_type":"metered","meter":"meter_...","pricing_model":{"kind":"per_unit","unitAmount":100}}' Response 201: { "object": "plan", "id": "price_...", "amount": "9000000", "interval": "month", "product": { "id": "product_...", "name": "Pro" }, "checkout_url": "https://soca.finance/pay/price_..." } Send subscribers to `checkout_url`. On the first successful charge a `subscription.active` webhook fires; renewals fire `subscription.renewed`. -------------------------------------------------------------------------------- ## The hosted checkout Both `checkout_url`s are fully hosted and non-custodial. The customer: 1. Connects a Solana wallet (Phantom/Solflare/Backpack) OR signs in with email/ Google (an embedded wallet is created for them). 2. Approves the payment (one signature; for subscriptions, an on-chain authorization capped at the plan price per period). 3. Pays in USDC with no gas. You never build payment UI. Just link to the `checkout_url`. -------------------------------------------------------------------------------- ## Read data — GET /v1/{resource} Resources: payments, subscriptions, invoices, events, meters. (Plans are create-only for now and are not yet listable.) curl https://soca.finance/v1/payments \ -H "Authorization: Bearer sk_live_..." Cursor pagination: `?limit=` (1–100, default 25) and `?before=` (an ISO timestamp from `next_cursor`). Response: { "object": "list", "data": [ ... ], "has_more": true, "next_cursor": "2026-07-17T12:00:00.000Z" } -------------------------------------------------------------------------------- ## Usage-based billing — POST /v1/meter-events Define meters in Dashboard -> Usage (each has an `event_name` and an aggregation: sum or count). Then ingest usage: Body (single event, or a batch as `{ "events": [ ...up to 500 ] }`): - event_name (required) the meter's event name — the meter must exist - customer_id (optional) Soca customer id - external_customer_id (optional) your own customer reference - value (optional) usage amount; count-meters default each event to 1 - timestamp (optional) ISO 8601; defaults to now - idempotency_key (optional) dedupe key — also accepted as an Idempotency-Key header - properties (optional) freeform object curl -X POST https://soca.finance/v1/meter-events \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"event_name":"ai_input_tokens","external_customer_id":"acct_42","value":"1520"}' Unknown `event_name` returns 404 unknown_meter. Ingestion is idempotent per idempotency_key — retries with the same key are safe. -------------------------------------------------------------------------------- ## Prepaid credits — /v1/credit_grants, /v1/credit_debits Top up a customer's prepaid balance (every movement is an immutable ledger row): POST /v1/credit_grants { "external_customer_id": "acct_42", "amount": "50000000", "reason": "Prepaid pack", "expires_at": "2027-01-01T00:00:00Z" } Draw down credits for usage; the response reports any `shortfall` (the part credits didn't cover, for you to bill separately). Pass `allow_negative: true` to let the balance go below zero instead: POST /v1/credit_debits { "external_customer_id": "acct_42", "amount": "12500000", "reason": "Token usage" } Both accept `idempotency_key` (body or Idempotency-Key header). Read the current balance: GET /v1/customers/{id}/credit_balance -------------------------------------------------------------------------------- ## Entitlements — feature access tied to billing Grant a feature (boolean or metered with a limit): POST /v1/entitlement_grants { "external_customer_id": "acct_42", "feature_key": "pro_models", "limit_value": "1000000", "active_until": "2026-12-31T00:00:00Z" } Check before serving a feature (the access-control call your backend makes): GET /v1/customers/{id}/entitlements/{feature_key} { "entitled": true, "limit": "1000000", "used": "136500", "remaining": "863500" } -------------------------------------------------------------------------------- ## Webhooks — fulfilling orders Register an HTTPS endpoint in Dashboard -> Developers -> Webhooks. You get a signing secret `whsec_...`. We POST JSON `{ id, type, created_at, data }` with headers: - `x-wc-signature: t=,v1=` - `wc-event-type: ` Verify with the SDK: import { verifyWebhookSignature } from '@usesoca/sdk'; if (!verifyWebhookSignature(process.env.SOCA_WEBHOOK_SECRET, rawBody, req.headers['x-wc-signature'])) { return res.status(400).end(); } Or by hand: HMAC-SHA256 of `"."` with your signing secret, compared (timing-safe) to `v1`. Reject if `t` is more than 5 minutes old. import crypto from 'node:crypto'; export function verifyWebhook(secret, rawBody, header) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1 || '', 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } Deliveries are retried with backoff on failure. ### Event types (these are the ONLY ones emitted) - invoice.paid — a one-time invoice was paid - refund.succeeded — a refund completed - subscription.active — first successful charge; access should start - subscription.renewed — a renewal charge succeeded - subscription.payment_failed — a renewal charge failed (dunning begins) - subscription.dunning_exhausted — retries exhausted; subscription lost - subscription.revoked — the customer revoked their on-chain authorization - subscription.expired — the subscription reached its end Fulfill one-time purchases on `invoice.paid`; grant subscription access on `subscription.active` and revoke on `subscription.revoked` / `subscription.dunning_exhausted` / `subscription.expired`. There is NO `payment.confirmed` event. -------------------------------------------------------------------------------- ## Telegram / Discord paid access Gate a Telegram channel or a Discord role behind a paid plan — no code needed: in Dashboard -> Settings -> Integrations, add your bot token (Telegram via @BotFather; Discord via the developer portal — the bot needs Manage Roles and its role must sit above the granted role) and pick the plan that grants access. After a customer subscribes, they message the bot, tap the link it replies with, and sign in with the same wallet they paid with (the wallet is bound server-side — never pasted). Access is granted automatically and revoked automatically when the subscription ends. Full setup guide: https://soca.finance/docs/developers/telegram-and-discord -------------------------------------------------------------------------------- ## MCP server (for AI agents and IDEs) Connect an AI client or IDE directly to this API over the Model Context Protocol. Add a remote MCP connector pointing at: https://soca.finance/api/mcp Authenticate one of two ways: - OAuth 2.1 (recommended): the client discovers the authorization server at https://soca.finance/.well-known/oauth-authorization-server, you approve access on a consent screen, and it receives a token. PKCE (S256) required; dynamic client registration supported. Unauthenticated requests to /api/mcp return 401 with a WWW-Authenticate header pointing at the resource metadata. - Static key: send Authorization: Bearer sk_live_... (the same key as the REST API). It is a stateless server exposing these tools: - create_invoice (amount, description, ...) -> invoice + checkout_url - create_plan (name, amount, interval, usage_type?, ...) -> plan + checkout_url (metered supported) - create_meter (key, display_name, aggregation?) -> meter - create_feature (key, name, type?) -> feature - create_meter_event (event_name, customer_id, value, ...) -> records usage - grant_credits / debit_credits / get_credit_balance -> prepaid balance - grant_entitlement / check_entitlement -> feature access - list_payments / list_subscriptions / list_invoices / list_events / list_meters (limit, before) The AI can then create billing objects (including usage-based plans and meters) and read state on your behalf. -------------------------------------------------------------------------------- ## Errors All errors are JSON: `{ "error": { "code": "...", "message": "..." } }`. - 401 unauthorized — missing/invalid/revoked key - 400 invalid_json — body was not valid JSON - 400 invalid_request — body failed validation (message says which field) - 400 mode_mismatch — key mode doesn't match this environment (test vs live) - 400 amount_too_small — below the minimum (plans: 1.00 USDC) - 404 resource_not_found / unknown_meter - 405 method_not_allowed — resource is read-only (or unsupported method) - 429 — rate limited -------------------------------------------------------------------------------- ## Quick reference - Amounts: USDC base units (6dp). $1 = 1000000. - SDK: npm i @usesoca/sdk -> new Soca(key); names match endpoints 1:1. - Plan intervals: week, biweekly, month, year, custom (+ interval_days 1–365). - Create: POST /v1/invoices, POST /v1/plans (both return checkout_url). - Read: GET /v1/payments|subscriptions|invoices|events|meters. - Usage: POST /v1/meter-events (idempotent; batch up to 500). - Credits: POST /v1/credit_grants, POST /v1/credit_debits, GET /v1/customers/{id}/credit_balance. - Entitlements: POST /v1/entitlement_grants, GET /v1/customers/{id}/entitlements/{feature_key}. - Fulfill: verify x-wc-signature, act on invoice.paid / subscription.active. - Human docs: https://soca.finance/docs · AI index: https://soca.finance/llms.txt