Bee (bee.redbroomsoftware.com)
Closed-loop community voucher currency. A community issues semillas (seeds) at a fixed 1 seed = 1 MXN parity; members spend them at the community's own stalls and merchants. Bee is the ledger and the rail — RBS owns the rail, each community is a tenant.
URL: https://bee.redbroomsoftware.comStatus: LIVE Tier: T2 (vertical SaaS primitive)
T1 apps are indispensable horizontal primitives. T2 apps are vertical SaaS or single-domain primitives — primitive-like in their domain but not universal dependencies.
Who this page is for
A merchant (or the POS/ERP behind it) that wants to let a customer pay part of a ticket with the vouchers of their community. You do not need to model the currency: Bee holds the balances, applies the acceptance rules, and tells you how much is still owed in money.
What a semilla actually is
Read this before writing code — the rules below are what makes the arithmetic come out the way it does.
- A voucher is a liability of the issuing cell, never of the community administration. A cell (célula) is a stall, a kitchen, a workshop — the unit that issues and accepts. The issuer travels with the voucher through peer-to-peer transfers and through charges, and is never rewritten.
- Fixed parity. 1 seed = 1 MXN, always. There is no internal exchange rate and no per-cell premium or discount.
- Acceptance is asymmetric, and this is the core rule: a ticket can be paid 100% with vouchers issued by the same cell that is charging, and up to 30% of the ticket in aggregate with vouchers issued by other cells. The issuing cell accepts its own vouchers with no cap and no right of refusal.
- Whole cents. The remainder that vouchers cannot cover is paid in money. Nothing is ever rounded in anyone's favour.
- Expiry is anchored to the batch at issuance (one year by default), not rolling. A voucher does not get younger because its issuer minted a new batch.
- Polen is a cell's monthly issuance quota. It counts both sources — what the administration disperses and what the cell mints as cashback at the register. Stalls under an umbrella cell consume the umbrella's quota, with a sub-limit.
- Closed loop. Vouchers are not redeemable for cash. There is no withdrawal path, by design.
The charge lifecycle
merchant bee customer
│ │ │
├─ POST /api/charge-requests ──▶│ creates the charge │
│ X-API-Key, Idempotency-Key │ → { id, token, url } │
│ │ │
├─ show the QR for `url` ───────┼────────────────────────────▶ │ opens /c/<token>
│ │◀──── applies their seeds ────┤ on their own phone
│ │ status → vales_applied │
│◀── signed webhook ────────────┤ (and/or you poll GET) │
│ valesAppliedCents + cashCents │
│ │ │
├─ collect the remainder in cash/card (bee is not involved) │
├─ POST /api/charge-requests/:id/settle ──▶ status → settled │
│◀── signed webhook ────────────┤ cashback minted on the money partTwo things about this shape:
- The customer spends their own balance. The merchant cannot apply a customer's vouchers on their behalf — not even to help.
applyrequires the payer's own session; your API key will not do it. That is the line between a wallet and a direct debit. - You decide when it is settled. Bee does not know whether the customer handed over the remaining cash.
settleis your statement that the sale closed.
A charge expires 30 minutes after creation if nothing is applied.
Authentication
Server-to-server calls carry a per-merchant key:
X-API-Key: <the key RBS issued to you>
Content-Type: application/jsonThe key is scoped to a specific list of cells. It authorises charging in those cells and nothing else: it cannot read or settle a charge in a cell it does not cover, and it cannot reach another community. Validation is fail-closed — a missing or malformed key configuration lets nobody in.
There is no OAuth flow for this surface. Customer identity lives inside Bee (the community roll); merchants never see it.
Endpoints
| Method | Path | Who calls it |
|---|---|---|
POST | /api/charge-requests | merchant (X-API-Key) or a cell operator's session |
GET | /api/charge-requests/:id | merchant, or the payer |
POST | /api/charge-requests/:id/apply | the payer only (their own session) |
POST | /api/charge-requests/:id/settle | merchant (X-API-Key) or a cell operator's session |
Create a charge
POST /api/charge-requests
X-API-Key: <your key>
Idempotency-Key: <your ticket number>
Content-Type: application/json
{ "cellId": "<cell-uuid>", "totalCents": 18000, "externalRef": "ORD-1042" }totalCentsis whole cents. Pesos with decimals are rejected — this would be the only place in the system where a float touches money.externalRefis your ticket reference. It is what identifies the sale on your side, and a charge created without it produces no close webhook (there is no external ticket to close). Send it.Idempotency-Keymakes a retried POST — the normal case when the network drops mid-request — return the same charge instead of creating a second one for the same amount. The charge token is derived from (key, cell), soORD-1042from two different merchants are two unrelated charges.
201 on creation, 200 if the idempotency key matched an existing charge. Both return the same shape:
{
"id": "<charge-uuid>",
"token": "K7M3QP2X9A",
"url": "https://bee.redbroomsoftware.com/c/K7M3QP2X9A",
"cellId": "<cell-uuid>",
"status": "pending",
"totalCents": 18000,
"valesAppliedCents": 0,
"cashCents": null,
"externalRef": "ORD-1042",
"expiresAt": "2026-08-01T19:03:11.520Z",
"settledAt": null,
"createdAt": "2026-08-01T18:33:11.520Z"
}Point the QR at url. The token is also printed in Crockford base32 (no I, L, O, U) so it can be read out loud when a camera fails.
Read a charge
GET /api/charge-requests/<id>
X-API-Key: <your key>Returns the same projection. Use it to poll while you wait — see Retries and what to do if you miss a webhook.
status moves pending → vales_applied → settled. Before the customer applies anything, cashCents is null; after that, valesAppliedCents + cashCents === totalCents, always, in whole cents. cashCents is what you still have to collect in money — Bee does not distinguish cash from card, because that is not its business.
The response is an explicit projection, not the row: it never tells you which account paid.
Settle
POST /api/charge-requests/<id>/settle
X-API-Key: <your key>
Content-Type: application/json
{ "stackable": false }Send "stackable": false when the sale already carries another promotion — seeds are not combinable with other discounts, and this is how you say so.
Settling is also the moment cashback is minted on the money part of the sale. The response tells you what actually happened:
{
"ok": true,
"duplicate": false,
"valesAppliedCents": 12000,
"cashCents": 6000,
"cashbackCents": 300,
"cashbackMinted": true,
"cashbackReason": null
}If the cell has run out of polen, the sale still settles and cashbackMinted is false with a cashbackReason. Show the customer the truth — "no cashback today" — rather than promising seeds that were never issued.
Errors
Business rejections come back as { "ok": false, "reason": "<code>" } with a meaningful status:
| Status | reason | Meaning |
|---|---|---|
400 | invalid_amount | totalCents is not a positive whole number of cents |
401 | — | no session and no X-API-Key |
403 | — | identified, but not scoped to that cell |
403 | cell_other_community | the cell belongs to another community |
404 | charge_not_found / cell_not_found | unknown, or not yours to see |
409 | charge_not_pending / already_settled | the charge is no longer in a state that allows this |
409 | insufficient_funds | the payer cannot cover what they asked to apply |
410 | charge_expired | the 30-minute window elapsed |
The close webhook
Once you register a destination with RBS, Bee posts to it when the charge changes hands. Two events, and only two:
eventType | When |
|---|---|
bee.charge.vales_applied | the customer applied their seeds |
bee.charge.settled | the merchant settled the charge |
POST <your registered URL>
Content-Type: application/json
X-Webhook-Signature: 8f3c… ← HMAC-SHA256, hex
X-Webhook-Timestamp: 1785000000 ← unix SECONDS
X-Webhook-Event-Id: bee.charge.settled:<charge-uuid>
X-Source-App: bee{
"eventId": "bee.charge.settled:<charge-uuid>",
"sourceApp": "bee",
"eventType": "bee.charge.settled",
"entityId": "<charge-uuid>",
"timestamp": "2026-08-01T18:33:11.520Z",
"version": "1.0",
"data": {
"chargeRequestId": "<charge-uuid>",
"externalRef": "ORD-1042",
"cellId": "<cell-uuid>",
"communityId": "<community-uuid>",
"status": "settled",
"totalCents": 18000,
"valesAppliedCents": 12000,
"cashCents": 6000,
"settledAt": "2026-08-01T18:33:11.520Z",
"occurredAt": "2026-08-01T18:33:11.518Z"
}
}What the payload deliberately does not carry: any personal data. No name, no phone, no payer account. Who paid with which account belongs to the community's roll, not to the merchant.
You register your endpoint together with a signing secret that is separate from your API key. That separation is on purpose: rotating your inbound key must not take your outbound notifications down with it.
Verifying the signature
The signature is HMAC-SHA256, hex, over the string `${timestamp}.${rawBody}` — the same format as the rest of the RBS ecosystem (@r-bsoftware/ecosystem-sdk → verifySignature; see Webhooks).
Sign over the RAW body, byte for byte
If your framework hands you parsed JSON and you re-serialize it, the signature will not match — one space or a different key order changes the digest. In Next.js: await request.text(), and then JSON.parse.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyBeeWebhook(rawBody, headers, secret) {
// FAIL-CLOSED: with no secret configured, accept nothing. "Let it through while we
// finish setting up" turns your receiver into a door anyone can close tickets through.
if (!secret) return { ok: false, reason: 'no_secret' };
const signature = headers['x-webhook-signature'] || '';
const ts = headers['x-webhook-timestamp'] || '';
if (!signature || !ts) return { ok: false, reason: 'unsigned' };
// Anti-replay: 300 s, the same window the rest of the ecosystem uses.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return { ok: false, reason: 'stale' };
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
// Length first: timingSafeEqual THROWS when the lengths differ.
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: 'bad_signature' };
return { ok: true };
}One-minute check from a terminal — the same computation the receiver does:
TS=1785000000
BODY='{"eventId":"bee.charge.settled:abc","sourceApp":"bee"}'
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$BEE_WEBHOOK_SECRET" -hexWhat to answer
| Response | What Bee understands |
|---|---|
2xx | delivered — never sent again |
5xx, 408, 429 | transient failure → retried |
any other 4xx | permanent rejection → not retried, logged as failed |
| nothing / timeout | same as 5xx |
Answer 2xx as soon as you have persisted the event, not when you have finished processing it. A receiver that does heavy work before replying earns itself a retry it did not need.
Duplicates
The eventId is deterministic: <eventType>:<chargeRequestId>. The same fact retried five times always arrives with the same id.
Store the eventId and drop repeats. It is the only defence against closing — and charging — the same ticket twice. Do not assume Bee sends exactly once: the retry exists precisely because we cannot know whether the first one landed. The two events of one charge have different ids (…vales_applied:<id> vs …settled:<id>), so deduplicating by eventId does not lose the second one.
Cases that produce no webhook, and are not failures
- A charge without
externalRef— it was born at a stall register or a printed QR, and there is no external ticket to close. - A cell with no destination registered. It is logged explicitly; it never fails silently.
- A retry with the same
Idempotency-Key: the writer returns the stored result without writing again, and does not notify again. Telling your register about the same payment twice would be a double charge.
Retries and what to do if you miss a webhook
Two mechanisms, deliberately two:
- In-process — up to 3 attempts with growing backoff (1 s, then 4 s), 3 s timeout per attempt. This covers transient failures, which is most of them.
- Durable log + daily resweep — every delivery leaves a row, and a daily sweep retries whatever is still pending.
Keep polling
The resweep runs once a day. For a restaurant ticket that is not a retry, it is archaeology. It exists so that nothing is lost silently, not so that it arrives on time. Poll GET /api/charge-requests/:id to cover the window between "the three attempts ran out" and "the sweep ran". The webhook is the improvement; the polling is the floor.
A receiver that is down never reverses, blocks or delays money already written. Delivery is fully detached from the charge path.
Events emitted
Bee also emits three events onto the internal RBS event bus. They are not part of the merchant contract — they exist for RBS-side accounting and metering, carry no personal data, and are not delivered to third-party endpoints:
| Event | Meaning |
|---|---|
bee.vale.minted | a batch of seeds was issued (dispersal or register cashback) |
bee.charge.settled | a charge was settled |
bee.vale.expired | an expiry sweep burned a batch |
Not available today
Stated plainly, because a published contract that is not honoured is worse than no contract:
- No cash redemption. Seeds are not convertible to pesos, and no endpoint will ever do it. This is a design line, not a missing feature.
- No online card payments through Bee. The MercadoPago path is switched off behind two independent locks and no live sale runs through it. Event ticket purchases fall back to a reservation; nothing pretends to be a payment.
- No La Hoja "pay with seeds" button yet. The producer side inside La Hoja's POS is not shipped, and La Hoja's orders are currently created already-paid, so there is no open ticket to attach a charge to. The Bee side of the contract — everything on this page — is live and testable today.
- No merchant-facing balance or reporting API. Reconciliation lives in Bee's own operator screens.
- No public sandbox. Ask RBS for a key scoped to a test cell.
Get started
Ask RBS for: an API key scoped to your cell(s), the cell id you will charge in, and — if you want the close webhook — your destination URL and its signing secret. There is no self-service onboarding for this surface yet.
See also: Webhooks · Idempotency · Errors