Keep your own store. Add Ask Lucilla as a way to pay.
Your server makes a checkout session, you send the customer to the page it gives you, and a signed webhook tells you when it is paid. The customer pays in USDC from their own Ask Lucilla wallet straight to your business wallet. Ask Lucilla never holds the money and takes no fee on the sale.
What happens, in order.
Read this once before you build. Every step below is how the code behaves today.
- 01
You make a session
Your server POSTs the cart. We check the arithmetic in whole cents, lock a CAD or USD to USDC rate for 15 minutes, and return the session with its url.
- 02
The customer pays
They open the url. The page shows the order and a code that opens the Ask Lucilla app, where they approve a USDC transfer with their passkey. They need the app and a wallet with USDC in it.
- 03
We check the chain
The payment counts only once the transfer to your wallet is read on the chain (Base today; the session's usdc.network names it), for at least the locked amount. Nothing the customer's screen says is trusted.
- 04
You are told
checkout.session.paid goes to your webhook. The session's status reads paid. The page shows a Back to shop button to your success_url.
- 05
The sale is in your books
If you connected Square, Clover or Shopify in Ask Lucilla, the paid sale is also recorded there. You can switch that off.
- 06
Refunds go back the same way
You record a refund through the API or dashboard, then send it from your wallet in the app. It is marked succeeded once the transfer to the customer is checked.
Three things to switch on.
All in the Ask Lucilla business dashboard, under Money, Pay with Ask Lucilla. Only the owner or an admin of the business can do this.
USDC on, wallet set up
Your business must accept USDC and have a wallet to receive it. Until then every new session is refused with usdc_not_accepted or business_wallet_missing.
An API key
Make one and copy it: it starts lsk_ and is shown once. We keep only a hash. Use it from your server only; the API sends no CORS headers, so a browser cannot call it.
A webhook URL
A public https:// address on a domain name (no IP addresses, port 443 only). The dashboard shows the signing secret (whsec_…) and can roll it.
https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1. The dashboard shows the same address. There is no api.asklucilla.com host yet.Authorization: Bearer lsk_… or X-API-Key: lsk_…. A missing, malformed or revoked key all get the same 401.POST the cart, get a url.
Send an Idempotency-Key (your order id is a good one) so a retry after a timeout returns the same session instead of a second one.
curl https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1/checkout/sessions \
-H "Authorization: Bearer lsk_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234" \
-d '{
"currency": "CAD",
"line_items": [
{ "name": "Blue mug", "quantity": 2, "unit_amount": 1250, "sku": "MUG-BLUE" },
{ "name": "Tote bag", "quantity": 1, "unit_amount": 3000 }
],
"tax_lines": [ { "label": "HST", "rate_pct": 13, "amount": 715 } ],
"total": 6215,
"external_reference": "order-1234",
"success_url": "https://shop.example/thanks",
"cancel_url": "https://shop.example/cart",
"metadata": { "order_id": "1234" }
}'HTTP/1.1 201 Created
{
"id": "cs_Vb2…",
"status": "open",
"url": "https://asklucilla.com/pay/cs_Vb2…",
"currency": "CAD",
"line_items": [
{ "name": "Blue mug", "quantity": 2, "unit_amount": 1250, "amount": 2500, "sku": "MUG-BLUE", "external_id": null },
{ "name": "Tote bag", "quantity": 1, "unit_amount": 3000, "amount": 3000, "sku": null, "external_id": null }
],
"tax_lines": [ { "label": "HST", "rate_pct": 13, "amount": 715 } ],
"subtotal": 5500,
"discount_amount": 0,
"shipping_amount": 0,
"tax_amount": 715,
"taxes_included": false,
"total": 6215,
"external_reference": "order-1234",
"metadata": { "order_id": "1234" },
"amount_source": "api",
"usdc": {
"network": "base",
"amount": "45.94128",
"amount_units": 45941280,
"rate": 0.7392,
"rate_source": "…",
"rate_locked_at": "2026-10-08T15:00:00.000Z",
"pay_to": "0x…your wallet…",
"fee_units": 0,
"tx_hash": null,
"paid_from": null
},
"created_at": "2026-10-08T15:00:00.000Z",
"expires_at": "2026-10-08T15:15:00.000Z",
"paid_at": null,
"amount_refunded": 0,
"refunds": [],
"livemode": true
}Request fields
{ label, amount, rate_pct? }, amount in cents. Use this when your platform already worked the tax out."tax": { "rate_pct": 13, "label": "HST", "included": false }. Send tax_lines or tax, not both.tax_lines nor tax, the shop's own tax rate from its Sales tax settings is applied (and taxes_included says whether your amounts already contain it). Send "tax": "none" for a sale with no tax. You are the seller and decide the tax; tax you send is recorded exactly as sent, never recalculated.In the response, usdc.amount is what the customer pays (6 decimals; amount_units is the same in millionths), usdc.rate is USDC per one unit of your currency at the locked rate (1 for USD and USDC sessions), and usdc.fee_units is always 0.
Redirect to the session's url.
The page lives at asklucilla.com/pay/{id}. On a phone, a button opens the Ask Lucilla app; on a computer, the customer scans the code on the page with the app.
checkout_id and checkout_status=paid added to the query string.checkout_status=cancelled; and after it expires, with checkout_status=expired.Know when it is paid.
A session is open, then paid, expired or cancelled. Paid and cancelled are final. An expired session can still turn paid if the customer's transfer was made in time and only its confirmation came late.
curl https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1/checkout/sessions/cs_Vb2… -H "Authorization: Bearer lsk_…"
curl -X POST https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1/checkout/sessions/cs_Vb2…/cancel -H "Authorization: Bearer lsk_…"
curl https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1/me -H "Authorization: Bearer lsk_…"
→ { "business_id": "…", "business_name": "…", "publishable_id": "lpk_…", "key_id": "…" }Events
refund_id names it in data.refunds.POST https://shop.example/hooks/ask-lucilla
Content-Type: application/json
User-Agent: AskLucilla-Pay/1
X-Lucilla-Event: checkout.session.paid
X-Lucilla-Delivery: checkout.session.paid:cs_Vb2…
X-Lucilla-Timestamp: 1791471600
X-Lucilla-Signature: v1=5d41402abc4b2a76b9719d911017c592…
{
"id": "checkout.session.paid:cs_Vb2…",
"event": "checkout.session.paid",
"occurred_at": "2026-10-08T15:03:12.000Z",
"business_id": "…",
"data": { …the session, exactly as GET returns it… }
}// Node.js (Express): keep the RAW body, the signature is over the exact bytes.
const crypto = require("crypto");
app.post("/hooks/ask-lucilla", express.raw({ type: "application/json" }), (req, res) => {
const secret = process.env.ASK_LUCILLA_WEBHOOK_SECRET; // whsec_… from the dashboard
const ts = Number(req.get("X-Lucilla-Timestamp"));
const body = req.body.toString("utf8");
const want = "v1=" + crypto.createHmac("sha256", secret).update(ts + "." + body).digest("hex");
const got = req.get("X-Lucilla-Signature") || "";
const fresh = Math.abs(Date.now() / 1000 - ts) <= 300;
const ok = fresh && want.length === got.length &&
crypto.timingSafeEqual(Buffer.from(want), Buffer.from(got));
if (!ok) return res.status(400).end();
const event = JSON.parse(body);
// X-Lucilla-Delivery / event.id is the same on every retry: ignore one you have seen.
if (event.event === "checkout.session.paid") markOrderPaid(event.data.external_reference, event.data);
res.status(200).end();
});X-Lucilla-Signature: v1= hex HMAC-SHA256 of <timestamp>.<raw body> with your whsec_ secret. Reject a timestamp more than 5 minutes from now.Record it here, send it from your wallet.
The customer paid your wallet directly, so the money is yours and only you can send it back. Ask Lucilla has no key to your wallet and never moves your money. A refund is therefore recorded first, then sent by you, then checked.
# Refund part of it: amount in cents of the session currency.
curl https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1/checkout/sessions/cs_Vb2…/refunds \
-H "Authorization: Bearer lsk_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-1234-1" \
-d '{ "amount": 1000, "reason": "One mug arrived broken" }'
# Leave "amount" out to refund everything not yet refunded.HTTP/1.1 201 Created
{
"refund": {
"id": "re_k3P…",
"amount": 1000,
"usdc_amount": "7.392",
"usdc_units": 7392000,
"status": "pending",
"reason": "One mug arrived broken",
"to": "0x…the customer's wallet…",
"created_at": "2026-10-09T10:00:00.000Z",
"succeeded_at": null
},
"session": { …, "amount_refunded": 1000, "refunds": [ …the refund above… ] }
}
curl https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1/checkout/sessions/cs_Vb2…/refunds -H "Authorization: Bearer lsk_…"
→ { "data": [ …every refund on this checkout… ] }Recorded: pending
The API (or the dashboard) records the refund on the session and as owed on the payment. checkout.refund.created fires. The business owners get a notification in the Ask Lucilla app.
Sent by you
In the app, the notification opens the payment. It shows the customer's address and the amount owed; you send it from your business wallet with your passkey.
Checked: succeeded
The app submits the transaction and we read it on the chain: from your wallet, to the customer, at least what is owed. Then every pending refund on the session turns succeeded and checkout.refund.succeeded fires.
One shape for every error.
A code you can switch on and a sentence you can show. 400 for a bad request (the code names the field), 401 bad key, 403 the key's maker no longer manages the business, 404 not found, 409 a state conflict, 422 refused by a rule, 429 rate limited, 503 USDC or the exchange rate is not available right now: try again shortly.
HTTP/1.1 422 Unprocessable Entity
{ "error": { "code": "amount_exceeds_refundable", "message": "amount is 7000 but only 5215 of 6215 is left to refund." } }Plainly.
No surprises later.
