Text Lucilla: +1 (314) 237-4046 or WhatsApp
For developers · Pay with Ask Lucilla

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.

How it works

What happens, in order.

Read this once before you build. Every step below is how the code behaves today.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Before you start

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.

API base URL
https://us-east1-asklucilla-app.cloudfunctions.net/payApi/v1. The dashboard shows the same address. There is no api.asklucilla.com host yet.
Authentication
Authorization: Bearer lsk_… or X-API-Key: lsk_…. A missing, malformed or revoked key all get the same 401.
Format
JSON in and out. Every amount is a whole number of cents of the session currency.
1 · Create a session

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

currency
CAD (the default), USD or USDC.
line_items[]
1 to 100. name (required), quantity (1 to 9999, default 1), unit_amount in cents (required), optional sku and external_id (your platform's id for the thing sold).
discount_amount, shipping_amount
Cents. Optional. The discount cannot be more than the items.
tax_lines[]
Up to 10: { label, amount, rate_pct? }, amount in cents. Use this when your platform already worked the tax out.
tax
Or one rate for us to apply to the goods after the discount: "tax": { "rate_pct": 13, "label": "HST", "included": false }. Send tax_lines or tax, not both.
taxes_included
true when the item prices already contain the tax lines.
no tax sent
When you send neither 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.
total
Optional, in cents. If you send it, it must equal items − discount + shipping + tax (tax not added when included) or the request is refused with total_mismatch. Nothing is ever silently corrected.
external_reference
Your order id, 1 to 120 characters. Shown to the customer and sent back in every webhook.
success_url, cancel_url
https URLs. Where the customer's Back to shop buttons go.
metadata
Up to 20 keys (letters, digits, _ . -), string values up to 500 characters. Returned as given.

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.

2 · Send the customer

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.

success_url
Once paid, the page offers a button back to it with checkout_id and checkout_status=paid added to the query string.
cancel_url
Offered while the session is open, with checkout_status=cancelled; and after it expires, with checkout_status=expired.
Do not trust the return
Landing on success_url proves nothing: anyone can type that URL. Fulfil on the webhook, or GET the session and check status is paid.
3 · Status and webhooks

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

checkout.session.paid
The customer's transfer was found and checked on the chain. Fulfil the order on this, not on the customer landing on your success_url.
checkout.session.expired
The 15 minutes ran out unpaid.
checkout.session.cancelled
You (or the dashboard) cancelled it.
checkout.refund.created
A refund was recorded and is owed to the customer. refund_id names it in data.refunds.
checkout.refund.succeeded
The refund's transfer from your wallet to the customer was checked on the chain.
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();
});
Signature
X-Lucilla-Signature: v1= hex HMAC-SHA256 of <timestamp>.<raw body> with your whsec_ secret. Reject a timestamp more than 5 minutes from now.
Each event once
One delivery per event per session (per refund for refund events). X-Lucilla-Delivery and the body's id stay the same across retries, so drop repeats.
Answer 2xx
Within 10 seconds. Redirects are not followed.
Retries
No answer, 408, 425, 429 or 5xx: tried again after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (6 attempts), then given up. Any other 4xx is not retried. A 429 Retry-After longer than the next wait is honoured, up to 6 hours.
No webhook URL
Nothing is sent. The session still turns paid; poll GET if you prefer.
4 · Refunds

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… ] }
  1. 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.

  2. 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.

  3. 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.

amount
Cents of the session currency, 1 or more. Leave it out for everything not yet refunded. Only a paid session can be refunded.
What the customer gets
That share of the USDC they actually paid, at the rate locked when they paid (no new exchange rate), rounded down to the millionth. The refund that completes the order returns exactly what is left, so a full refund is exactly what was paid.
Never more than was paid
All refunds on a session together cannot pass its total. Over it: 422 amount_exceeds_refundable. Nothing left: 409 fully_refunded. At most 20 refunds per session.
Idempotency-Key
Strongly recommended. The same key returns the same refund (200); the same key with a different amount is refused (409 idempotency_key_reused).
Where it goes
Back to the customer who paid: their current Ask Lucilla wallet, otherwise the address the payment came from. Never to you or to Ask Lucilla. 409 no_customer_wallet if neither is known.
reason
Optional text up to 200 characters, kept on the refund.
Your own system
A refund here does not change the order in Square, Clover or Shopify. Refund or adjust it there yourself.
Cards
Checkout sessions are paid in USDC only, so there is no card refund on this API.
Without a server

The pay button.

For a site with no backend: a script tag and a div. Switch the button on in the dashboard and list the websites it may run on; a request from any other site is refused. The amount then comes from the page, so anyone can change it in their browser: check the paid amount in the webhook before you ship anything, or use data-session with a session your server made.

<script async src="https://asklucilla.com/pay.js"></script>
<div data-ask-lucilla-pay
     data-key="lpk_…"
     data-amount="45.20" data-currency="CAD"
     data-tax-amount="5.20" data-tax-label="HST"
     data-name="Blue mug" data-reference="order-1234"
     data-success-url="https://shop.example/thanks"></div>

<!-- Or, safer: make the session on your server and only open it here. -->
<div data-ask-lucilla-pay data-key="lpk_…" data-session="cs_Vb2…"></div>

data-amount is the total the customer pays, tax included, as a decimal; data-tax-amount is the part of it that is tax. Your publishable id (lpk_…) is public and safe in a page; your API key is not.

Errors

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." } }
Fees, test mode, what is not here

Plainly.

No surprises later.

Ask Lucilla fee
None. A checkout session has no fee leg: usdc.fee_units is always 0 and the whole amount goes to your wallet.
Test mode
There is none yet. Every session is live (livemode is always true) and moves real USDC on the live chain. To try it end to end, make a small order (the minimum is 0.50), pay it from your own phone, then refund it.
Currencies
Price in CAD, USD or USDC. The customer always pays USDC.
Host
The API is on the Google Cloud address above. A branded api.asklucilla.com is not set up.
Plugins
There is no WooCommerce plugin or Shopify app to install. Use the API from your store's backend, or the pay button.

Stuck? Write to us.

Send the session id and what you expected. Terms · Privacy

support@asklucilla.com