SDK
npm install @speciehq/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 "@speciehq/sdk";
const specie = new Specie({
apiKey: process.env.SPECIE_API_KEY!,
baseUrl: "https://specie.site",
});
const payment = await specie.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 specie.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 specie.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 "@speciehq/sdk";
app.post("/webhooks/specie", 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.SPECIE_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 specie.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 "@speciehq/sdk";
try {
await specie.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
}
}
Full example
A complete Express server: create a payment, send the customer to the hosted checkout, confirm on return, and fulfil from the webhook. Copy it into a file and run it.
import express from "express";
import { Specie, constructEvent } from "@speciehq/sdk";
const specie = new Specie({ apiKey: process.env.SPECIE_API_KEY! });
const PUBLIC_URL = process.env.PUBLIC_URL ?? "http://localhost:3000";
// Your catalog and orders live in your app; a Map stands in here.
const PLAN = { name: "Pro plan — 30 days", amount: "25.00" };
const orders = new Map<string, string>(); // orderId -> paymentId
const app = express();
app.get("/", (_req, res) => {
res.type("html").send(`<h1>${PLAN.name}</h1><p>${PLAN.amount} USDC</p>
<form method="post" action="/checkout"><button>Pay with USDC</button></form>`);
});
// Create a payment and send the customer to Specie's hosted checkout.
app.post("/checkout", express.urlencoded({ extended: false }), async (_req, res) => {
const orderId = `order_${Date.now()}`;
const payment = await specie.payments.create({
amount: PLAN.amount,
description: PLAN.name,
reference: orderId,
successUrl: `${PUBLIC_URL}/thanks?order=${orderId}`,
metadata: { orderId },
idempotencyKey: orderId,
});
orders.set(orderId, payment.id);
res.redirect(payment.pay_url);
});
// The return page is public, so confirm server-side before granting anything.
app.get("/thanks", async (req, res) => {
const paymentId = orders.get(String(req.query.order ?? ""));
if (!paymentId) return res.status(404).send("Unknown order");
const payment = await specie.payments.retrieve(paymentId);
const paid = payment.status === "confirmed" || payment.status === "overpaid";
res.send(paid ? "Payment received — thank you." : `Not paid yet (${payment.status}).`);
});
// The webhook is the source of truth. The raw body is required for verification.
app.post("/webhooks/specie", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = constructEvent({
payload: req.body,
signature: req.header("x-specie-signature"),
secret: process.env.SPECIE_WEBHOOK_SECRET!,
});
} catch {
return res.status(400).send("Invalid signature");
}
if (event.event === "payment.confirmed") {
console.log("Paid:", event.data.metadata?.orderId, event.data.amount);
// Grant access, ship the goods, mark the order paid.
}
res.sendStatus(200);
});
app.listen(3000, () => console.log(`Shop on ${PUBLIC_URL}`));
Get SPECIE_API_KEY and a webhook signing secret from your dashboard. To receive webhooks on localhost, expose the port with a tunnel (for example ngrok http 3000) and point a Specie webhook endpoint at /webhooks/specie. /thanks is a convenience for the payer — access is granted from the verified webhook or a server-side retrieve, never from the redirect alone.