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
}
}