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.