Start here

From API key to verified USDC payment.

Create a Checkout Intent from your backend, redirect the payer to SubScript, and fulfill your order from a signed webhook. This guide starts with a working sandbox request, then explains every identifier, security boundary, and production decision.

5 minutes

First sandbox Checkout Intent

OpenAPI + llms.txt

Machine-readable specs for humans and agents

Self-testable

CLI trigger, local listener, and sandbox test clocks

Endpoint decision — make this before writing the request

/api/intent is one-time only and never appears in DM plan controls. Use /api/v1/plans for reusable recurring tiers and /api/v1/subscriptions for recurring authorization. Recurring-only fields are rejected by the intent endpoint; recurring-looking titles require an explicit one-time confirmation.

Use caseCorrect endpointResult
One-time paymentPOST /api/intentOne-time hosted checkout only; never a recurring or DM plan.
Public recurring planPOST /api/v1/plansReusable tier shown in merchant plans, user DMs, and the public subscribe flow.
User-specific subscription checkoutPOST /api/v1/subscriptions + subscriberRecurring checkout and targeted offer for that user.
DM-visible subscription checkoutPOST /api/v1/subscriptions + publishToDm: trueRecurring product shown in the dashboard and DM plan flow.
Metered billingPOST /api/user/vault/report-usageAccrues usage against the user's merchant vault.

First successful integration

Create a hosted checkout in five minutes

Your backend creates an intent, your frontend redirects to its hosted checkout URL, and your webhook fulfills the order after SubScript verifies the Arc settlement. You never need to map a payer wallet to your user.

1

Get a test key

Open Dashboard → Developers → API keys and create an sk_test_ key.

2

Keep it server-side

Save it as SUBSCRIPT_SECRET_KEY. Never prefix it with NEXT_PUBLIC_.

3

Choose your order ID

Use your user, order, or invoice ID as externalReference so fulfillment maps cleanly.

dotenv
# .env.local — server only
SUBSCRIPT_SECRET_KEY=sk_test_your_secret_key
SUBSCRIPT_WEBHOOK_SECRET=whsec_your_endpoint_secret

Register the webhook while creating the API key

The dashboard setup sends POST /api/keys with an optional webhookUrl. A successful response reveals both the API secret and webhookEndpoint.secret once. If endpoint registration fails after the key is created, copy the key and follow the returned webhookWarning to add the endpoint from Developers → Webhooks.

1. Create the Checkout Intent

This request is safely retryable because the idempotency key is stable for this logical checkout.

bash
curl --request POST \
  --url https://www.subscriptonarc.com/api/intent \
  --header "Authorization: Bearer sk_test_your_secret_key" \
  --header "Content-Type: application/json" \
  --data '{
    "title": "Order #1042",
    "description": "One-time account activation",
    "amountUsdcMicros": "15000000",
    "externalReference": "order_1042",
    "idempotencyKey": "checkout_order_1042",
    "sandbox": true,
    "successUrl": "https://yourapp.com/billing/success",
    "cancelUrl": "https://yourapp.com/pricing"
  }'

2. Store the response, then redirect

Persist intent.id, your externalReference, and receiptToken before sending the browser to checkoutUrl.

json
{
  "success": true,
  "sandbox": true,
  "intent": {
    "id": "clx_intent_123",
    "checkoutSessionId": "clx_intent_123",
    "object": "payment_intent",
    "paymentType": "one_time",
    "appearsInDmPlanPicker": false,
    "title": "Order #1042",
    "amountUsdcMicros": "15000000",
    "status": "PENDING",
    "receiptToken": "rcpt-7e10c918a3aa672eb783f1b965914b12",
    "checkoutUrl": "https://www.subscriptonarc.com/pay/clx_intent_123",
    "chainId": 5042002,
    "usdcAddress": "0x3600000000000000000000000000000000000000"
  }
}
Fulfillment rule: never unlock from the success redirect alone. A settled checkout adds subscript_verification_status=settled, but that confirms SubScript settlement—not webhook delivery or fulfillment in your application. Unlock only after a valid, idempotently processed payment.succeeded webhook, or reconcile from GET /api/intent/:id.

Mental model

Four identifiers, one predictable lifecycle

Most integration mistakes come from treating identifiers as interchangeable. Give each one a single job and persist the relationship in your database.

intent.id

SubScript's checkout identifier

Use this to correlate checkout, webhook, receipt, and support requests.

externalReference

Your identifier

Set this to your user ID, order ID, or invoice ID. It returns as merchant_reference.

receiptToken

Human-readable proof handle

Links the hosted checkout to its Arc memo receipt without exposing raw chain complexity.

event.id

Webhook delivery identifier

Store it under a UNIQUE constraint before fulfillment so retries cannot duplicate work.

Step 1

Create intent

PENDING

Step 2

Redirect payer

Hosted checkout

Step 3

Verify settlement

Arc USDC

Step 4

Receive webhook

payment.succeeded

Step 5

Fulfill once

Your database

Money units: amountUsdcMicros is always a positive integer string in six-decimal micro-USDC. "15000000" means 15 USDC; "1" means 0.000001 USDC. Never send floats.

Protocol brief

UPA, live primitives, and deployment-scoped targets

The protocol brief translates the updated feature document into the platform boundary: what is live today, what problem each flow solves, and what should remain caveated until production deployment settings prove it.

Open brief

Choose your integration path

No-code merchant

Create a payment link in the merchant dashboard, copy the URL or QR code, and paste it into your product, Notion page, Linktree, or checkout screen.

Vibecoder

Paste the prompt below into your coding agent. It tells the agent to create Checkout Intents, store intent IDs, redirect users, and verify webhooks.

Backend developer

Use the REST API to create Checkout Intents and a signed webhook route to fulfill purchases in your own database.

Protocol team

Use Viem/Ethers to route USDC transfers through SubScript contracts and Arc memo payloads directly.

Unified Payment Authorization model

SubScript's Unified Payment Authorization model gives one-time payments, subscriptions, usage events, invoices, and AI-native transactions the same operational shape: a merchant creates a structured authorization, the payer approves a bounded USDC action, SubScript records the receipt, and signed webhooks tell the merchant what to unlock.

Consumer control

Users authorize bounded payment flows and can avoid unwanted recurring charges, hidden card fees, overdraft-style penalties, and opaque dispute trails.

Merchant certainty

Merchants receive intent IDs, webhook events, retry-aware billing state, payment links, and audit-friendly Arc receipt records instead of raw wallet guesswork.

Protocol coverage

Current platform surfaces include Checkout Intents, payment links, metered vaults, signed webhooks, receipts, DNS-style aliases, premium privacy flows, retries, reconciliation, and keeper-triggered renewals.

Circle developer-controlled custody, direct fiat-to-USDC onramps, dedicated invoice terms, sponsor workflows, service lock windows, minimum commitment periods, configurable dunning schedules, and fully decentralized Chainlink Automation are protocol targets documented in the feature brief. Google social sign-in is paused until Circle identity is verified server-side. The current app already provides the integration primitives those features build on: intents, subscriptions, retries, keeper routes, webhooks, receipts, and merchant dashboards.

No-code setup: payment links and QR checkout

  1. 1. Sign up as a merchant and open the SubScript merchant dashboard.
  2. 2. Create a payment link with amount, title, description, and optional customer reference.
  3. 3. Copy the hosted checkout URL or QR code.
  4. 4. Put the URL behind your pricing button, invoice, Discord message, or email campaign.
  5. 5. When the payer completes checkout, SubScript records the payment, creates a receipt, and can notify your backend through webhooks.
Best for creators, small SaaS teams, vibe-built products, and early pilots that need payments live before a full backend integration exists.

Vibecoder prompt

If you are building with an AI coding agent, paste this directly into it. The important thing is that your app stores the SubScript `intent_id` beside your own user record and waits for the signed webhook before unlocking access.

prompt
You are integrating SubScript into my app.

First classify the billing model. Never choose an endpoint from the product title alone:
- ONE-TIME order, activation fee, invoice, or intentionally non-renewing pass:
  POST /api/intent. It never creates a recurring plan and never appears in DM plan controls.
- REUSABLE recurring weekly/monthly/yearly tier:
  POST /api/v1/plans once, then POST /api/v1/subscriptions with planId.
- CUSTOMER-SPECIFIC recurring offer:
  POST /api/v1/subscriptions with amount + interval + subscriber + merchantCustomerId.

Goal:
- Add the correct SubScript checkout button for my billing model.
- Store the returned resource id and my external account/order reference before redirecting.
- Redirect the user to the returned checkoutUrl.
- Add a webhook route that reads the raw body, verifies the timestamped x-subscript-signature, and atomically claims event.id.
- For one-time payments, fulfill only after payment.succeeded.
- For recurring access, process subscription.created/updated/renewed/payment_failed/canceled.

Use:
- Amount: 15 USDC
- Product: decide whether this is a one-time purchase or a recurring plan before coding
- Webhook path: /api/subscript-webhook
- Env vars: SUBSCRIPT_SECRET_KEY and SUBSCRIPT_WEBHOOK_SECRET

Important:
- Do not ask the merchant to know the payer wallet.
- Use intent_id only for one-time payments; use planId/subscription_id for recurring billing.
- Never send interval, subscriber, planId, publishToDm, or recurring products to /api/intent.
- Never label a Checkout Intent "weekly", "monthly", or "subscription" unless it is intentionally
  a one-time pass and confirmOneTime: true is supplied.
- Send amountUsdcMicros as an integer string ("15000000" = 15 USDC).
- Use one stable idempotencyKey per logical checkout and reuse it only for retries.
- Never fulfill from the success redirect; fulfill only from the verified webhook.
- Hosted checkout is Arc-native USDC only right now; do not add Base, Solana, or CCTP checkout unless the local docs say it is live.
- Treat fiat onramps, dedicated invoices, sponsor workflows, merchant commitment windows, and Chainlink Automation as deployment-scoped unless the local app explicitly implements them.
- Keep all secret keys server-side only.

REST API reference

Create a Checkout Intent

POST/api/intent

Base URL

https://www.subscriptonarc.com

Authentication

Authorization: Bearer sk_test_…

Content type

application/json

FieldTypeRequiredMeaning
titlestringYesShort one-time purchase name shown at checkout.
amountUsdcMicrosinteger stringYesCanonical six-decimal amount. "15000000" = 15 USDC.
externalReferencestring ≤ 256RecommendedYour user, order, or invoice ID. Returned in the webhook.
idempotencyKeystringRecommendedStable key for one logical checkout. Reuse it only when retrying that checkout.
descriptionstringNoCustomer-facing context for the payment.
sandboxbooleanNoCredential-owned test mode. sk_test_ keys set this true and settle valueless USDC on Arc Testnet.
successUrlHTTPS URLNoWhere checkout sends the payer after success. Not proof of payment.
cancelUrlHTTPS URLNoWhere checkout sends the payer after cancellation.
expiresAtISO date or Unix timeNoWhen the hosted checkout should stop accepting payment.
maxUsesinteger 1–10000NoMaximum successful uses for a reusable link.
confirmOneTimebooleanOnly for ambiguous titlesSet true only when wording such as "1 week pass" is intentionally non-renewing.
javascript
// Run this on your server — never in a browser component.
const response = await fetch("https://www.subscriptonarc.com/api/intent", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBSCRIPT_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "Order #1042",
    amountUsdcMicros: "15000000", // 15 USDC; always an integer string
    description: "One-time account activation",
    externalReference: "order_1042",
    idempotencyKey: "checkout_order_1042",
    sandbox: true,
    successUrl: "https://yourapp.com/billing/success",
    cancelUrl: "https://yourapp.com/pricing",
  }),
});

const payload = await response.json();

if (!response.ok) {
  console.error("SubScript request failed", {
    code: payload.code,
    requestId: payload.request_id,
  });
  throw new Error(payload.message || "SubScript checkout creation failed");
}

// Persist all three beside your own order/user before redirecting.
const checkoutUrl = payload.intent.checkoutUrl;
const intentId = payload.intent.id;
const receiptToken = payload.intent.receiptToken;
tsx
// Frontend: open hosted checkout in a new tab so your app keeps its state.
// After settlement, checkout routes the payer back to your successUrl with
// ?subscript_status=success&subscript_checkout_id=...&subscript_receipt_id=...&subscript_tx_hash=...
// (treat those as navigation hints only — confirm payment via webhook or the intent status API).
export function UpgradeButton({ checkoutUrl }) {
  return (
    <a href={checkoutUrl} target="_blank" rel="noopener" className="subscript-button">
      Pay with SubScript
    </a>
  );
}

Status polling

Use GET /api/intent/:id for support tools, dashboards, and agent-driven test loops. The legacy query form GET /api/intent/status?id=... remains supported. Anonymous calls return aggregate status only; pass your Authorization: Bearer sk_... key (or call from a signed-in dashboard session) to also receive latestPayment — payer identity and transaction proof are visible only to the merchant who owns the checkout. Fulfillment should still happen from the signed webhook.

javascript
// Poll when you need a synchronous status check.
// Webhooks remain the source of truth for fulfillment.
const status = await fetch("https://www.subscriptonarc.com/api/intent/clx_intent_123");
const { intent } = await status.json();

if (intent.status === "PAID") {
  // Safe to reconcile dashboards or support views.
  // Fulfillment should still be idempotent and webhook-driven.
}

201

Created

A new intent was created.

200

Replay

The same idempotency key returned its existing intent.

4xx

Fix request

Use code for branching and message for display.

5xx

Retry safely

Reuse the same idempotency key and log request_id.

Fixed-schedule recurring billing

Create weekly, monthly, or custom subscriptions

POST/api/v1/subscriptions

SubScript supports fixed-schedule subscription checkouts today. Create a subscription from your backend, redirect the customer to the hosted checkout, and listen for subscription lifecycle webhooks. Metered vaults are a separate usage-based product, not a workaround for subscriptions.

Recurring products publish to the merchant dashboard and DM plan picker by default. Supplying subscriber creates a targeted plan and offer DM; set publishToDm: false only when the checkout is intentionally private. Customer plan changes are upgrade-only; do not build or expose a downgrade action.
FieldTypeRequiredMeaning
amountUsdcMicrosinteger stringYes, unless planIdRecurring charge amount in micro-USDC.
planIdstringOptionalUse a saved merchant plan for amount and interval.
intervaldaily | weekly | monthly | yearlyYes, unless planId or intervalSecondsNamed fixed schedule.
intervalSecondsintegerOptionalCustom schedule in seconds.
intervalCountintegerOptionalMultiplier for the interval; defaults to 1.
subscriber0x addressOptionalPreselect the expected subscriber wallet.
merchantCustomerIdstring ≤ 256With subscriberYour durable user/account binding. Persists through DM upgrades and webhooks.
publishToDmbooleanNo; defaults truePublishes the product to dashboard/DM controls. Subscriber-assigned products are targeted.
idempotencyKeystringRecommendedStable key for one logical subscription checkout.
javascript
const response = await fetch("https://www.subscriptonarc.com/api/v1/subscriptions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBSCRIPT_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "Kris's Script Pro",
    amountUsdcMicros: "7000000",
    interval: "weekly",
    subscriber: "0xCustomerWallet...",
    merchantCustomerId: "user_123",
    publishToDm: true,
    idempotencyKey: "sub_user_123_pro_weekly",
    sandbox: true,
  }),
});

const { subscription } = await response.json();

// Redirect to hosted checkout. It becomes active after the customer
// authorizes the bounded recurring payment on-chain.
return redirect(subscription.checkoutUrl);
json
{
  "success": true,
  "sandbox": true,
  "subscription": {
    "id": "sub_7f9c5f1e-4a1f-4b4f-bbc1-761b34c0eebb",
    "object": "subscription",
    "status": "incomplete",
    "merchantAddress": "0xMerchant...",
    "subscriber": "0xCustomerWallet...",
    "amountUsdcMicros": "7000000",
    "amountUsdc": "7",
    "intervalSeconds": 604800,
    "intervalCount": 1,
    "interval": "weekly",
    "checkoutUrl": "https://www.subscriptonarc.com/pay/7f9c5f1e-4a1f-4b4f-bbc1-761b34c0eebb"
  }
}

incomplete

Created but not authorized yet. Redirect the customer to checkoutUrl.

active

The customer authorized the recurring payment on-chain. Fulfill from the signed webhook.

canceled

Unaccepted checkout sessions can be withdrawn by the merchant; active authorizations are customer-controlled.

Webhook events: subscription.created, subscription.updated, subscription.renewed, subscription.payment_failed, and subscription.canceled. The CLI can send signed local samples with npx @subscriptonarc/cli trigger subscription.renewed --url http://localhost:3000/api/webhooks/subscript.

Plan catalog: /api/v1/plans

A subscription checkout and its reusable catalog plan are distinct records. Amount-plus-interval subscription requests publish the companion plan by default; create stable tiers directly in the plan catalog. This is the same catalog the dashboard Plans tab, customer DMs, and /subscribe links read, so plans created here and in the dashboard always stay in sync. GET /api/v1/plans lists your plans (each with its shareable subscribeUrl and any live introductory promotion), POST /api/v1/plans creates one (name, amountUsdc, periodDays), and PATCH /api/v1/plans updates active, description, or detailsUrl. Pass a plan's planId to POST /api/v1/subscriptions to generate checkouts against it.

javascript
// Create the reusable tier once. It appears in the merchant dashboard
// and in the plan controls of every existing user DM with this merchant.
const response = await fetch("https://www.subscriptonarc.com/api/v1/plans", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBSCRIPT_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Kris's Script Pro — Weekly",
    amountUsdcMicros: "7000000",
    periodDays: 7,
    description: "Recurring weekly Pro access",
  }),
});

const { plan } = await response.json();

// Later, create a customer checkout against the canonical plan:
await fetch("https://www.subscriptonarc.com/api/v1/subscriptions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBSCRIPT_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    planId: plan.id,
    subscriber: "0xCustomerWallet...",
    merchantCustomerId: "user_123",
    idempotencyKey: "sub_user_123_kris_pro_weekly",
  }),
});

Pay-per-use billing with commit vaults

For metered products that do not fit fixed monthly plans, SubScript uses on-chain commit vaults. The platform fixes the commitment at 2 USDC; the customer escrows it once per cycle, and their service stays active while you report usage. Funds are guaranteed up to the committed balance — you are not chasing per-call card charges.

API & AI tokens

Bill API calls, model tokens, or agent runs as they happen instead of forcing every customer into a static tier.

Per-session access

Charge per session, render, or job — gate each one on the vault status in a single request.

Pay-per-view items

Settle small purchases for articles, clips, data exports, or premium actions without an all-access plan.

How a developer integrates pay-per-session

  1. The commitment is platform-fixed. Every customer escrows the standard 2 USDC per cycle — it is not merchant-configurable (GET /api/merchant/vault/commit-config returns the policy), and your drawable settlement is capped at the same 2 USDC per customer per cycle.
  2. Customer commits once per cycle. They open /dashboard/user?tab=commit, choose your merchant address, and escrow the standard 2 USDC from their SubScript wallet. The vault goes active for the 30-day cycle; settlement closes it, so the next cycle requires a fresh commitment.
  3. Check readiness. Call GET /api/user/vault/status?userAddress=0x... with your secret key before rendering a metered session. It returns NO_VAULT, VAULT_INACTIVE, or VAULT_ACTIVE, plus a dashboard URL to show the customer when they need to commit.
  4. Report before you serve. Call POST /api/user/vault/report-usage with your secret key before rendering each unit, and serve only on a 200. A 402 means do not serve: either the vault is inactive (VAULT_INACTIVE) or the charge would exceed the remaining escrow (COMMIT_EXHAUSTED). Reporting after you serve risks eating the last unit's cost yourself.
  5. Get paid at cycle end. SubScript's keeper draws the accrued total from escrow; you withdraw with merchantClaim. A report that would exceed escrow is rejected outright and the response's remainingUsdc shows what's left, so the customer can never be charged past what they committed — and funds are never pulled from their main wallet.
javascript
// Merchant backend: check readiness, then ALWAYS call report-usage BEFORE
// you serve the unit of work. report-usage both ACCRUES the charge and tells
// you whether access is allowed — treat any non-200 as "do not serve".
// The customer commits to your vault once; you never collect per call.

const statusRes = await fetch(
  "https://www.subscriptonarc.com/api/user/vault/status?userAddress=0xCustomerWallet...",
  { headers: { Authorization: `Bearer ${process.env.SUBSCRIPT_SECRET_KEY}` } }
);
const status = await statusRes.json();

if (!status.active) {
  return showCommitPrompt(status.onboarding?.dashboardUrl);
}

const res = await fetch("https://www.subscriptonarc.com/api/user/vault/report-usage", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.SUBSCRIPT_SECRET_KEY}`, // server-side only
  },
  body: JSON.stringify({
    userAddress: "0xCustomerWallet...",
    amountUsdc: "0.50", // price of this session / unit of work
  }),
});

if (res.status === 402) {
  const body = await res.json();
  // Two "do not serve" cases:
  //  - VAULT_INACTIVE:    owes a balance or dropped below your required commit.
  //  - COMMIT_EXHAUSTED:  this charge would exceed their remaining escrow. The
  //    whole request is rejected — nothing accrues, so a customer can never be
  //    charged past what they committed. body.remainingUsdc tells you what's
  //    left; you may retry with a smaller unit (<= remainingUsdc) if that fits.
  return denySession(body); // ask them to re-commit (or serve a smaller unit)
}

const usage = await res.json();
// 200 == accrued and within escrow — safe to serve.
// usage.active === true, usage.accruedUsageUsdc grows over the 30-day cycle.
grantSession();

// You don't collect per call. At cycle end SubScript's keeper draws the accrued
// total from the customer's escrow; you withdraw it with merchantClaim
// (Merchant dashboard -> Vault, or POST /api/merchant/vault/claim).
Keep SUBSCRIPT_SECRET_KEY server-side only. Usage accrues off-chain during the cycle and settles on-chain at cycle end; the customer's escrow guarantees you payment up to the committed amount. Direct bank-transfer fiat-to-USDC funding remains provider/compliance-scoped until a live onramp is wired.

Trusted fulfillment

Verify the webhook, then fulfill exactly once

A redirect says where the browser went. A signed webhook says what settled. Read the raw request bytes, verify the timestamped HMAC, claim the event ID atomically, and only then update your order or entitlement.

Event-sourced webhook dispatch

Every webhook is recorded in the merchant_events ledger before dispatch. Each delivery attempt is logged on a best-effort basis to webhook_delivery_attempts with the HTTP status, response body, and attempt timestamp; attempt rows may be missing if persistence fails after the HTTP request. Endpoints are environment-scoped (TEST or LIVE) so sandbox and production traffic never cross. Secret rotation is supported with a grace-period overlap — the previous signing secret stays valid until it expires, giving you time to update your handler without missing events.

Dashboard delivery health APIs

Signed-in Premium merchants can inspect GET /api/webhooks/endpoints and GET /api/webhooks/events (with cursor pagination and ?type= / ?environment= filters), resend a selected event with POST /api/webhooks/events/replay, or send a signed sample through POST /api/webhooks/test. Test event types are test, payment.succeeded, and subscription.created. The dashboard shows the exact endpoint, HTTP status, response body, and delivery time so a missing endpoint or failed response is visible immediately. Send { "latest": true } to the replay endpoint for the one-click "Resend latest" flow.

Step 1

Read raw body

Parsing and re-serializing JSON changes the signed bytes.

Step 2

Check ±5 minutes

Reject stale timestamps before computing trust.

Step 3

Verify HMAC

Sign timestamp + period + exact raw body with SHA-256.

Step 4

Claim event.id

A UNIQUE insert makes retries safe under concurrency.

Canonical event: type: "payment.succeeded". Use data.intent_id to find the SubScript checkout and data.merchant_reference to find your own user or order. The legacy event: "payment.success" alias is present only for compatibility.

Sponsored plan fulfillment & Ask a Friend DMs

A user with zero balance or insufficient funds can request plan sponsorship via an in-app DM or shareable off-platform link with POST /api/user/requests/merchant-plan (accepting sendDirectMessage: true and targetPeer). This dispatches a SPONSORED_PLAN_REQUEST card in the User A ↔ Friend B DM thread. The single-use checkout is a one-time gift payment for the plan's regular price and one billing duration.

Upon payment, SubScript dispatches a SPONSORED_PLAN_CONFIRMED Merchant DM to User A with a resubscribePlanId payload so User A can self-fund future renewals with a "Resubscribe for Yourself" button. In payment.succeeded, check data.isSponsored, data.beneficiary_address, data.sponsoredPlanId, and data.durationSeconds. Credit the beneficiary, not necessarily the payer. If that beneficiary already has active access, extend the existing access window by durationSeconds instead of rejecting the webhook or creating a duplicate subscription.

json
{
  "id": "evt_payment_abc123",
  "type": "payment.succeeded",
  "created": 1783080000,
  "data": {
    "intent_id": "clx_intent_123",
    "merchant_reference": "user_123",
    "amount": "15",
    "amount_usdc_micros": "15000000",
    "currency": "USDC",
    "beneficiary_address": "0xbeneficiary...",
    "beneficiaryAddress": "0xbeneficiary...",
    "isSponsored": true,
    "sponsoredPlanId": "plan_123",
    "sponsoredPlanName": "Pro Weekly",
    "durationSeconds": 604800,
    "receipt_id": "rcpt-7e10c918a3aa672eb783f1b965914b12",
    "transaction_hash": "0x...",
    "chain_id": 5042002,
    "usdc_address": "0x3600000000000000000000000000000000000000"
  }
}
Keep SUBSCRIPT_SECRET_KEY and SUBSCRIPT_WEBHOOK_SECRET server-side only. Never expose either value in React props, mobile clients, public repositories, browser bundles, logs, or screenshots.
javascript
import crypto from "crypto";

export async function POST(req) {
  const rawBody = await req.text();
  const signatureHeader = req.headers.get("x-subscript-signature");
  const secret = process.env.SUBSCRIPT_WEBHOOK_SECRET;

  if (!secret || !signatureHeader) {
    return Response.json({ error: "Missing webhook configuration or signature" }, { status: 400 });
  }

  const match = signatureHeader.match(/^t=(\d+),v1=([a-f0-9]{64})$/);
  if (!match) {
    return Response.json({ error: "Malformed signature" }, { status: 401 });
  }

  const timestamp = Number(match[1]);
  const digest = match[2];
  const now = Math.floor(Date.now() / 1000);

  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > 300) {
    return Response.json({ error: "Expired signature" }, { status: 401 });
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const received = Buffer.from(digest, "hex");
  const expectedBuffer = Buffer.from(expected, "hex");
  if (received.length !== expectedBuffer.length || !crypto.timingSafeEqual(received, expectedBuffer)) {
    return Response.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(rawBody);

  // Insert event.id into a UNIQUE column before fulfilling.
  // If it already exists, return 200 without running fulfillment again.
  const inserted = await claimWebhookEvent(event.id);
  if (!inserted) return Response.json({ received: true, duplicate: true });

  if (event.type === "payment.succeeded") {
    await unlockPlanForUser(event.data.intent_id);
  }

  return Response.json({ received: true });
}

Delivery behavior

  • Return any 2xx only after the event is durably claimed.
  • SubScript retries timeouts, 408, 429, and 5xx responses. Each attempt is logged on a best-effort basis with its HTTP status and response body.
  • Your handler must return 200 for an already-processed event.id.
  • Do slow email, analytics, or provisioning work after the durable claim, preferably through your own queue.
  • The merchant dashboard shows delivery attempts per event on a best-effort basis, so most failed retries are visible without server-side logging.

Ship with confidence

Test, observe, and go live deliberately

Build the complete test flow before swapping credentials. Test and live modes use the same API shape, so your code should change configuration—not logic.

ModeCredentialBehaviorUse it for
Arc Testnetsk_test_…Implies sandbox: true and settles valueless test USDC on Arc Testnet. The shared public demo key is simulation-only.Funded testnet integration, CI, and end-to-end settlement tests.
Livesk_live_…Requires a configured merchant payout wallet.Real customer settlement after launch review.

Local signed event

npx @subscriptonarc/cli trigger payment.succeeded --url http://localhost:3000/api/webhooks/subscript

Forward real test events

npx @subscriptonarc/cli listen --forward-to http://localhost:3000/api/webhooks/subscript

Simulate renewals

POST /api/test/clocks, attach a subscription, then POST /api/test/clocks/:id/advance

Sandbox acceptance checklist

  • Create an intent and persist all identifiers before redirect.
  • Complete checkout and receive payment.succeeded.
  • Replay the same webhook and prove fulfillment happens once.
  • Retry intent creation with the same idempotencyKey and receive the same intent.
  • Send an invalid amount and confirm your logs capture request_id, never the secret key.

Go-live checklist

  • Create a separate sk_live_ key and store it only in server secrets.
  • Configure and verify the merchant payout destination.
  • Use a distinct live webhook endpoint secret.
  • Alert on webhook 5xx responses and aged PENDING intents.
  • Keep the funded Arc testnet path available for release regression tests.

Fast diagnosis

401 unauthorized
Confirm the Bearer header exists and the key is active. Do not print the key while debugging.
400 invalid_amount
Send a positive integer string in micro-USDC; never send 15.00.
409 idempotency conflict
The key belongs to another logical resource. Generate a new key for the new checkout.
merchant_payout_wallet_missing
Your live key is valid, but live checkout is blocked until payout setup is complete.
Webhook signature mismatch
Verify against the raw body before JSON parsing and use the endpoint's exact secret.

Error responses

Every non-2xx response from the API carries a machine-readable envelope. Branch on `code` (stable identifier), show `message` to humans, and quote `request_id` when contacting support — server logs are indexed by it.

json
{
  "error": "Bad Request: amountUsdcMicros is required and must be a positive integer in micro-USDC",
  "code": "invalid_amount",
  "message": "Bad Request: amountUsdcMicros is required and must be a positive integer in micro-USDC (e.g. \"15000000\" = 15 USDC). amountUsdc is accepted as an alias with the same unit.",
  "request_id": "3f6a1f6e-9d2b-4c1a-8f7e-2b9d4c1a8f7e",
  "doc_url": "https://www.subscriptonarc.com/docs#errors"
}

Common codes

  • unauthorized — missing/invalid `Authorization: Bearer sk_…` header. Keys live in Dashboard → Developers → API keys.
  • invalid_json — request body is not valid JSON.
  • missing_title / invalid_amount — validation failures return `400` with the field named in `message`.
  • merchant_payout_wallet_missing — live key with no payout wallet configured; `resolution_url` points at the settings page.
  • quota_exceeded — active-link tier limit reached (`403`).
  • idempotency_key_conflict — the key was already used for a different resource (`409`).
  • internal_error — a `500` with no internals leaked; report the `request_id`.

Human-readable receipts with Arc memos

SubScript receipts are designed for humans, not explorers. A payer can share a URL like `www.subscriptonarc.com/receipt/rcpt-7e10c918a3aa672eb783f1b965914b12`, while SubScript indexes the Arc memo and displays amount, sender, merchant, date, note, and transaction status.

Default visibility

Receipt data is intended for the payer, merchant, and SubScript by default. Future invite flows can selectively disclose a receipt to another viewer.

Proof without confusion

The receipt page hides raw transaction complexity while preserving auditability through Arc memo indexing.

Advanced: Arc memo transaction payload

Merchant hosted links settle through the SubScript Router: the receipt token is passed as the router memo, and the backend verifies the matching `DepositWithMemo` event before marking the payment paid. User-created receive links settle as direct Arc USDC transfers to the requester, with the backend verifying the ERC-20 `Transfer` call and event. Cross-chain CCTP checkout is disabled for hosted payment links until Arc-side mint and memo settlement can be verified in one bound flow.

typescript
import { parseUnits } from "viem";

const receiptToken = "rcpt-7e10c918a3aa672eb783f1b965914b12";

await walletClient.writeContract({
  address: SUBSCRIPT_ROUTER_ADDRESS,
  abi: [{
    type: "function",
    name: "depositForMerchant",
    stateMutability: "nonpayable",
    inputs: [
      { name: "merchant", type: "address" },
      { name: "amount", type: "uint256" },
      { name: "memo", type: "string" },
    ],
    outputs: []
  }],
  functionName: "depositForMerchant",
  args: [merchantAddress, parseUnits("15", 6), receiptToken],
});

FAQ

How easy is integration?

A no-code merchant can launch with a hosted link in minutes. A developer can add intent creation and webhook fulfillment in under an hour if their app already has user accounts.

Can I test before setting a payout wallet?

Yes. Use a `sk_test_` key to settle valueless test USDC on Arc Testnet. The shared public demo key remains simulation-only. Live keys require a configured payout destination and return `merchant_payout_wallet_missing` if setup is incomplete.

Can SubScript handle usage-based products?

Yes. Commit vaults let a customer escrow the platform-fixed 2 USDC commitment once per cycle; the merchant reports API calls, tokens, sessions, or per-item access via the usage API, which accrues the charges and gates access. SubScript draws the accrued total from escrow at cycle end, closes the vault, and requires a fresh commitment for the next cycle.

Can someone else sponsor a subscription?

The protocol model supports sponsored payment relationships such as parents, employers, or teams covering costs while keeping the subscriber's usage context separate. Dedicated sponsor records, spending caps, and revocation policies are still deployment-scoped.

Can users export their wallet key?

Legacy email wallets can be exported only after fresh OTP step-up verification. Circle developer-controlled MPC wallets do not expose a raw private key. Google sign-in is paused until its identity and custody flow is verified server-side.

How does SubScript compare to streaming payment protocols?

SubScript uses Permit2-style bounded allowances rather than continuous locked streaming liquidity, so funds can remain liquid in the user's wallet until a billing-cycle transaction executes.

Can merchants enforce lock windows?

The UPA model includes service lock windows, minimum commitments, and grace periods, with a ceiling of 72 hours for digital goods and 30 days for SaaS seats. These terms need explicit schema, contract enforcement, and UI disclosure before live use.

Does SubScript have smart dunning?

The platform has retry, reconciliation, billing, and notification primitives. Configurable Day 1, Day 3, and Day 7 schedules plus email/SMS top-up reminders should be formalized before calling it fully live.

Does the merchant need to track wallets?

No. The merchant should track Checkout Intent IDs. SubScript maps wallet payment activity to the off-chain intent and sends the signed result.

What does the user pay?

The user pays the advertised USDC price. SubScript is designed around predictable Arc USDC gas and sponsored-fee flows so users avoid hidden card-style fees.

Why is this better than dollar cards?

Users avoid virtual card setup fees, maintenance fees, failed transaction penalties, KYC delays for basic wallet setup, billing-address failures, and FX markup surprises.

What problem does SubScript solve?

It prevents unwanted recurring charges, double-billing, hidden cancellation traps, overdraft-style penalties, and opaque receipt disputes by moving billing state into transparent programmable payment logic.

Does SubScript provide invoices?

The current product supports payment links, Checkout Intents, receipt records, and external references that cover invoice-like collection. A dedicated invoice engine with custom due terms is documented as a protocol target.

Does SubScript use decentralized keepers?

The codebase has keeper-compatible contract and API surfaces today. Full Chainlink Automation as the default execution network should be treated as a roadmap or deployment configuration item until the production keeper network is wired.

Integration Docs | SubScript