Guides
The card rail with Stripe Issuing
Bind a virtual card to an agent and the card issuer asks Immiscible in real time before money moves. No receipt, no payment. Every authorisation, approved or declined, is evidence.
An agent that holds a card number can spend without asking anyone. The card rail turns that around: the agent holds a card that only works against a receipt. Stripe Issuing (or any issuer through the generic adapter) asks Immiscible during authorisation, and Immiscible approves only when the agent holds an unused receipt that covers the charge. The approval uses the receipt up.
agent asks the gate ──> allow + receipt ──> agent pays with its card
│
Stripe Issuing asks ─────┘
Immiscible: is there an unused receipt for this
payee and currency covering this amount? yes: approve, spend it
no: decline#Rules at the card
Several conditions hold at the card itself, whatever the agent’s receipt says:
- Cash, transfer and gambling merchant codes never pay.
- A grocery receipt pays only under grocery merchant codes.
- A tolerance over the receipt applies only where tips happen.
- Captures add up; an over-capture freezes the agent under an incident hold.
- A force post with no authorisation freezes the agent.
- Replays get their first answer.
- Anything that cannot be verified, read or decided declines. A refusal is always a
200withapproved: false, because an error status would hand the decision to the issuer’s own stand-in, which may approve.
Clearing, not the agent’s own report, settles the action: what the issuer says was captured is what the ledger records.
#1. Create the Stripe webhook
In the Stripe dashboard, for your Issuing account:
- Add a webhook endpoint at
https://immiscible.fly.dev/issuing/<workspace id>/stripe. The console shows the exact URL under Agents, Cards. - Subscribe to
issuing_authorization.request,issuing_authorization.created,issuing_authorization.updatedandissuing_transaction.created. - Turn on real-time authorisations for the endpoint, and copy its signing secret (
whsec_...).
#2. Connect the issuer
An owner saves the signing secret. Connecting an issuer widens what money can move, so with anyone else in the workspace it is a proposal a second owner confirms; alone in a workspace, the owner acts alone.
curl -X PUT "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/issuing/stripe" \
-H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1" \
-H "content-type: application/json" \
-d '{ "secret": "whsec_...", "overPct": 0 }'const res = await fetch('https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/issuing/stripe', {
method: 'PUT',
credentials: 'include',
headers: { 'content-type': 'application/json', 'x-immiscible-csrf': '1' },
body: JSON.stringify({ secret: process.env.STRIPE_ISSUING_WHSEC, overPct: 0 }),
});
// 200 when you are alone in the workspace; 202 with { proposal } when a second owner must confirmA second owner confirms with POST /api/w/:wid/issuing/proposals/:pid/confirm. A proposal lapses if its proposer stops being an owner.
#3. Bind a card to an agent
Create a virtual card for the agent in Stripe, then bind its id to the agent (also dual control in a team):
curl -X POST "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/agents/agt_4f2c91a7/cards" \
-H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1" \
-H "content-type: application/json" \
-d '{ "issuer": "stripe", "cardRef": "ic_1Q2w3E4r5T6y", "last4": "4242" }'Removing a card narrows what money can move, so it never waits for a second owner: DELETE /api/w/:wid/cards/:cid.
#4. Pay
The agent asks the gate first, exactly as in the quickstart. On allow it holds a receipt for that payee, currency and amount; it then pays with its card, and the authorisation succeeds only because the receipt exists. Without asking first, the card declines.
Every authorisation is under Cards in the console and at GET /api/w/:wid/card-authorizations.
#Any other issuer
The generic adapter takes two signed calls:
POST /issuing/$IMMISCIBLE_WORKSPACE/generic/authorize HTTP/1.1
immiscible-signature: t=1790444901,v1=5d41402abc4b2a76b9719d911017c592...
content-type: application/json
{ "authRef": "auth_881", "cardRef": "card_19", "amount": 4200, "currency": "GBP", "merchant": { "name": "Grocer", "domain": "grocer.example", "mcc": "5411" }, "eventId": "evt_77" }POST /issuing/$IMMISCIBLE_WORKSPACE/generic/clear HTTP/1.1
immiscible-signature: t=1790445002,v1=7c211433f02071597741e6ff5a8ea34...
content-type: application/json
{ "kind": "capture", "authRef": "auth_881", "cardRef": "card_19", "amount": 4200, "currency": "GBP", "eventId": "evt_78" }The signature is an HMAC-SHA256 of <t>.<raw body> with the issuer secret you saved for generic, within five minutes. kind is capture, refund or closed. The answer to an authorisation is always 200 with { "approved": true } or { "approved": false, "reason": "..." }. A capture Immiscible could not record is answered 503 so the issuer sends it again, and is recorded as an incident.