SDK

npm install @specie/sdk

No dependencies. Node 20+. Server-side only — the API key can create payments, so there is deliberately no browser build.

Create a payment

import { Specie } from "@specie/sdk";

const arc = new Specie({
  apiKey: process.env.ARC_API_KEY!,
  baseUrl: "https://your-deployment",
});

const payment = await arc.payments.create({
  amount: "25.00",
  description: "Pro plan — 30 days",
  reference: order.id,
  successUrl: "https://yourapp.com/thanks",
  metadata: { orderId: order.id, plan: "30d" },
  idempotencyKey: order.id,
});

redirect(payment.pay_url);

A plan catalog

Keep prices in your own code, not ours. This is the shape most integrations end up with:

export const PLANS = {
  "7d":  { id: "7d",  label: "7 days",  days: 7,  amount: "5.00"  },
  "30d": { id: "30d", label: "30 days", days: 30, amount: "20.00" },
  "90d": { id: "90d", label: "90 days", days: 90, amount: "55.00" },
} as const;

export async function checkout(planId: keyof typeof PLANS, user: User) {
  const plan = PLANS[planId];

  return arc.payments.create({
    amount: plan.amount,
    description: `${plan.label} subscription`,
    reference: `${user.id}-${planId}-${Date.now()}`,
    successUrl: `https://yourapp.com/welcome?plan=${planId}`,
    customerEmail: user.email,
    metadata: { userId: user.id, plan: planId, days: plan.days },
  });
}

Put in metadata whatever your webhook needs to fulfil the order. It comes back untouched.

Itemised checkout

await arc.payments.create({
  lineItems: [
    { description: "Pro plan (30 days)", unit_amount: "20.00", quantity: 1 },
    { description: "Extra seat",         unit_amount: "5.00",  quantity: 2 },
  ],
  successUrl: "https://yourapp.com/thanks",
});

The amount is derived — 30.00 — so the breakdown a customer reads always matches the charge.

Verify a webhook

import { constructEvent } from "@specie/sdk";

app.post("/webhooks/arc", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = constructEvent({
      payload: req.body,                        // RAW body
      signature: req.header("x-specie-signature"),
      secret: process.env.ARC_WEBHOOK_SECRET!,
    });
  } catch {
    return res.status(400).send("Invalid signature");
  }

  if (event.event === "payment.confirmed") {
    grantAccess(event.data.metadata?.userId, event.data.metadata?.plan);
  }

  res.sendStatus(200);
});

Pass the raw body. JSON.stringify(JSON.parse(raw)) produces different bytes and the signature will not match. In Express that means express.raw(), not express.json().

Reconcile a missed webhook

const payment = await arc.payments.retrieve(paymentId);
if (payment.status === "confirmed") fulfil(payment);

Worth calling on the page a payer lands on after success_url. Anyone can navigate to that URL — only this call proves the payment settled.

Errors

import { SpecieError } from "@specie/sdk";

try {
  await arc.payments.create({ amount: "0.01" });
} catch (err) {
  if (err instanceof SpecieError) {
    console.error(err.status, err.message); // 400 Amount is below the minimum of 0.5 USDC
  }
}