@speciehq/sdk
v0.1.0
Published
Server-side SDK for Specie — accept USDC payments on Arc
Maintainers
Readme
@speciehq/sdk
Accept USDC payments on Arc from your server.
No dependencies. Node 20+.
npm install @speciehq/sdkCreate 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", // decimal string, never a float
description: "Pro plan — 30 days",
reference: order.id, // your order id, shown at checkout
successUrl: "https://yourapp.com/thanks",
metadata: { orderId: order.id },
idempotencyKey: order.id,
});
// Send the customer here, or pass payment.id to <Specie />.
console.log(payment.pay_url);Amounts are decimal strings because USDC has six decimal places and floating
point cannot represent them exactly. 0.1 + 0.2 is the wrong amount to charge
someone.
Retrying with the same idempotencyKey returns the original payment rather than
creating a second one.
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, // the 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") {
fulfil(event.data.metadata?.orderId);
}
res.sendStatus(200);
});Pass the raw body. JSON.stringify(JSON.parse(raw)) produces different
bytes and the signature will not match — this is the most common integration
mistake. In Express that means express.raw(), not express.json().
constructEvent throws rather than returning false, so an unverified payload
cannot be used by accident. It also rejects timestamps outside a five-minute
window, which is what stops a captured request being replayed later.
Events
| Event | Meaning |
|---|---|
| payment.confirmed | Full amount received and settled |
| payment.underpaid | Something arrived, but less than invoiced |
| payment.overpaid | More than invoiced arrived |
| payment.expired | Window closed with nothing received |
| payment.failed | Settlement reverted; funds are recoverable |
Only payment.confirmed means you have been paid in full. Treat
payment.underpaid as unpaid until the remainder arrives.
Reconcile after the redirect
successUrl brings the payer back to your site, but anyone can navigate to that
URL. Confirm server-side before granting anything:
const payment = await specie.payments.retrieve(paymentId);
if (payment.status === "confirmed") fulfil(payment);Security
The API key can create payments, so it must stay on your server. There is deliberately no browser build of this package.
Full documentation: https://specie.site/docs
