Entitlement Reconciliation
One buyer, one payment, one entitlement. There is no billing service, no
Stripe integration in box and no payment webhook —
moon/AGENTS-FIRST.md
D2 decided the mechanism and box/docs/BOUNDARIES.md still holds the
line that produced it:
No billing/Stripe integration, and no payment webhooks. We sell, but the mechanism is Stripe Payment Links plus manual reconciliation: a human confirms payment and grants the entitlement through the existing operator endpoints. Box never talks to a payment processor.
So the mechanism is a person, and this page is that person’s procedure. It should take about ten minutes for a buyer who already has an account, and longer for one who does not.
Every endpoint, request body and status code below was read out of the code
before it was written down: box/config/routes.rb,
box/app/controllers/** and box/db/schema.rb for the grant side,
and identity/openapi/v1.yaml for the lookup side. Where a step is not backed
by code, it says so and says what to do instead — that is the convention this
site runs on, and the two steps that fail it are marked [UNVERIFIED] rather
than filled in.
What you need
Section titled “What you need”boxrunning and reachable, andidentityreachable from it. The staff gate is an HTTP call toidentityon every grant and revoke, so identity being down is a 503 and not a permission problem.- A staff identity session. Staff is a property of the user, not of a
membership —
identity’s own words are that “no account role implies global staff authority”. An account owner is not staff. curl. Nothing else. There is no CLI in the loop and nocafesubcommand that grants anything.- The Stripe Payment Link the buyer used, so you can read the payment off it.
What the endpoints are, before you start
Section titled “What the endpoints are, before you start”GET /v1/operator/entitlements staff list, filterablePOST /v1/operator/entitlements staff grantPOST /v1/operator/entitlements/:id/revoke staff revokeRead from box/config/routes.rb, namespace :operator, path: "/v1/operator". The :id in the revoke route is the entitlement row’s
integer id, not the account id — you get it from the grant response or from
the list.
Two more routes matter for verification at the end, and are customer-facing rather than staff:
GET /v1/accounts/:account_id/entitlements what the buyer can seePOST /v1/accounts/:account_id/artifact_fetches what spends the entitlementStep 1 — the payment arrives on Stripe, not on us
Section titled “Step 1 — the payment arrives on Stripe, not on us”The buyer pays a Stripe Payment Link. Nothing arrives in cafaye. No
webhook, no processor_webhooks row, no event on a bus, no email. The payment
exists in Stripe’s dashboard and nowhere else, and that is deliberate rather
than a gap in wiring.
Open the Payment Link in the Stripe dashboard — Payments, or the link’s own Payments view — and find the one you are about to grant for. What you are looking for is spelled out in step 2; the point of this step is only that you know where to look, because there is no cafaye-side view of it and there is never going to be one under D2.
Step 2 — confirm the payment is real before you grant anything
Section titled “Step 2 — confirm the payment is real before you grant anything”Four things to read off the Stripe payment, in this order. Granting before all four are true is how a refunded or mis-addressed payment becomes an entitlement nobody can take back cleanly.
The amount. One-time, and one of the four decided prices:
| Unit | Price |
|---|---|
Platform (cafe + kit) |
$120/yr |
identity |
$240/yr |
courier |
$240/yr |
| All three | $420/yr |
A monthly amount, a subscription, or any figure not in that table is not a sale under D2’s price list. Stop and ask.
The product. Stripe’s Payment Link is per product, so the link itself names
the unit — but confirm which unit the payment is for before you choose a
product for the grant, because the grant’s product is what the entitlement
check later reads. The four values box accepts are kit, platform,
identity and courier (Entitlement::PRODUCTS); a paid unit maps to
platform, identity or courier, and the free kit entitlement is the one
a claim buys, not a payment.
The status. Paid and settled, not pending, not refunded, not disputed. A dispute is a grant you may have to undo.
The buyer’s email. This is the one that goes wrong most often. Stripe collects an email at checkout, and it is not always the one on the account.
When the email does not match an account
Section titled “When the email does not match an account”This is common and it is not a dead end. Three shapes:
- They paid with a different address than the one they registered with. Search identity’s directory for the address that is on the account, and use that account. See step 3.
- The address on the payment is one they do not control — a typo, a work address they no longer read, or somebody else’s. Do not grant against a guess. Write to them on a channel you already have and get a working address.
- They paid from a card whose holder name looks like a company and the email is a personal one. Same rule: get the address that owns the account.
What you must not do is create an account from the Stripe email and grant against that. An entitlement granted to a fresh account nobody signs into is a paid-for entitlement with no buyer in it, and it is invisible to the person who paid.
Step 3 — find the account in identity
Section titled “Step 3 — find the account in identity”The buyer’s email is the key. Identity has a staff directory search for exactly this, and it is the only way to turn an address into an id:
GET /v1/operator/users?query=<the buyer's email>identity/openapi/v1.yaml describes it as “a bounded, minimal user directory
search for staff: the users whose email or display name contains the query,
ordered by email”. The parameters:
queryis required, and at least two characters after trimming. An empty or one-character term is a422— identity’s own words are that it is “a directory dump wearing a query string”.limitis optional; absent means 25, and over 50 is a422naminglimitrather than a silent truncation.LIKEmetacharacters match themselves, so%does not become the whole table.
It answers 200 with the users’ ids, emails, display names, disabled and
verified timestamps and creation times — and “nothing a reader could mistake
for a credential: no password digest, no API key, no recovery token, no MFA
secret, no session token”.
What it does not give you is the account. There is no GET /v1/operator/users/{id}/accounts, and no way to ask identity which accounts a
user belongs to. Identity’s account routes are all member-scoped and a
non-member’s read is the same 404 as an unknown account, so you cannot walk
accounts looking for one either.
[UNVERIFIED] — resolving a user id to an account id is not something any
endpoint in identity/openapi/v1.yaml does. Do not invent one. The account
id has to come from somewhere else:
- Ask the buyer. They can read their own account list and send you the id. This is the fastest reliable route and it is the one to default to.
- Read it off an existing grant. If they have any prior entitlement —
including a claim-granted
kitone — the operator list atGET /v1/operator/entitlements?account_id=…can be reached in the other direction once you have one account id, but you need an id to start. - Look at box’s rows.
entitlements.account_idis a string column with no foreign key and no cross-service lookup, so a previous grant against that account is findable from box’s database but not from identity’s API.
The verification at the end of this procedure (step 9) is what turns “the account id I was given” into “the account id this buyer is in” — the customer-facing read is member-scoped, so if the buyer can see the entitlement, the id was right.
Step 4 — grant the entitlement
Section titled “Step 4 — grant the entitlement”The exact endpoint
Section titled “The exact endpoint”POST /v1/operator/entitlementsStaff-checked against identity on every request. The route is registered in
box/config/routes.rb under namespace :operator, path: "/v1/operator".
The exact request body
Section titled “The exact request body”The controller reads params[:account_id], params[:product],
params[:version_range], params[:source] and params[:reference] — all
flat, at the top level of the JSON body, not nested under a wrapper key:
export C=http://box.example # wherever box really isexport ACC='<the account id from step 3>'export STAFF_TOKEN='<your staff session token>'
curl -s -o grant.json -w 'HTTP %{http_code}\n' \ -X POST "$C/v1/operator/entitlements" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $STAFF_TOKEN" \ -d '{ "account_id": "'"$ACC"'", "product": "identity", "version_range": null, "source": "reconciled_payment", "reference": "stripe_payment_link_123" }'cat grant.json| Field | Required | What it is |
|---|---|---|
account_id |
yes | the identity account UUID, as a string. Entitlement validates it against UUID_RE, so a malformed one is a 422, not a silent miss |
product |
no — defaults to kit |
kit, platform, identity or courier. For a paid unit it is never kit. The default is a trap in this procedure: omit it and you grant the free claim entitlement |
version_range |
no | a cafaye semver constraint (^X.Y.Z, ~X.Y.Z, >=X.Y.Z, or exact); null means every version. A paid unit bought without a negotiated version bound is null |
source |
no | free text. Use reconciled_payment — it is the value box’s own test uses, and it is what makes the row findable as a reconciled sale |
reference |
no | free text, external reference. Put the Stripe payment id here — it is the only join between an entitlement row and the money that bought it |
The staff token is what records the actor. Box resolves it by forwarding
it to GET {BOX_IDENTITY_URL}/v1/operator/me and reading
user["id"] out of the 200; that id is what lands in granted_by. It is
not a token you mint on box — box verifies nothing itself and
holds no local user store. The whole of that resolution, quoted from
box/app/clients/identity_client.rb:
def staff_user_id!(token) res = get("/v1/operator/me", token) case res.status when 200 user = res.body.is_a?(Hash) ? (res.body["user"] || {}) : {} unless user["staff"] raise Forbidden, "not staff" end raise Unavailable, "staff answer missing user id" if user["id"].to_s.empty? user["id"].to_s when 401 then raise Unauthenticated, "invalid token" when 403 then raise Forbidden, "not staff" when 404 then raise Forbidden, "not staff" else raise Unavailable, "identity answered #{res.status}" endendSo the actor recorded is your own identity user id, and it is the truth of
who did this. There is no service account, no system actor and no way to
grant unattributed. If your token is not staff, the grant does not happen.
What a duplicate grant returns — verified, and quoted
Section titled “What a duplicate grant returns — verified, and quoted”The docs say 409 with the existing row. That is in
box/app/controllers/operator/entitlements_controller.rb, and it is
worth quoting in full because there are two code paths that produce it, and
the second one is the one that matters under concurrency:
def create actor = require_staff return if actor.nil?
account_id = params[:account_id].to_s product = params[:product].to_s.presence || "kit"
existing = Entitlement.find_by(account_id: account_id, product: product, status: "active") if existing return render json: { entitlement: serialize_entitlement(existing), duplicate: true }, status: :conflict end
begin entitlement = Entitlement.grant!( account_id: account_id, product: product, version_range: params[:version_range].to_s.presence, source: params[:source].to_s.presence, reference: params[:reference].to_s.presence, granted_by: actor ) rescue ActiveRecord::RecordInvalid => e return render_unprocessable(e.record) rescue ActiveRecord::RecordNotUnique clash = Entitlement.find_by(account_id: account_id, product: product, status: "active") return render json: { entitlement: serialize_entitlement(clash), duplicate: true }, status: :conflict end
render json: { entitlement: serialize_entitlement(entitlement) }, status: :createdendstatus: :conflict is 409, and the body is {entitlement: …, duplicate: true}. The first path is a read-then-write race; the second is
ActiveRecord::RecordNotUnique, which is what you get when the read found
nothing and the database refused anyway. The database can refuse because of the
index, quoted from box/db/schema.rb:
t.index ["account_id", "product"], name: "index_entitlements_on_account_and_product_active", unique: true, where: "((status)::text = 'active'::text)"A partial unique index on (account_id, product) where status = 'active'.
So exactly one active entitlement per account per product, and a second grant
of the same product for the same account is a 409 no matter how the two
requests interleave.
Safe to retry is true of the 409 and only of the 409. A 409 means the
entitlement is already there. Do not change the body and try again — change
the product if you meant a different unit, and revoke the wrong one if you
granted the wrong one.
The 201 body, from serialize_entitlement in
box/app/controllers/application_controller.rb:
{"entitlement":{"id":42,"account_id":"…","product":"identity","status":"active", "version_range":null,"source":"reconciled_payment","reference":"stripe_payment_link_123", "granted_by":"99999999-9999-4999-8999-999999999999","revoked_by":null, "granted_at":"2026-10-10T…","revoked_at":null}}granted_by is the identity user id from step 3’s staff check. revoked_by and
revoked_at are null until somebody revokes. Copy the id out of this
response — it is the :id the revoke route takes, and it is the only moment
you see it next to the row without querying.
Step 5 — renewal: a second link a year later
Section titled “Step 5 — renewal: a second link a year later”Renewal is the same procedure. Second Payment Link, second payment, second grant — and a difference you have to know about before you run it.
The old entitlement does not expire, because nothing expires it.
box/db/schema.rb’s entitlements table has columns for account_id,
product, status, source, reference, granted_by, revoked_by,
revoked_at, created_at, updated_at and version_range — and no
expires_at. Status is active or revoked and nothing else
(Entitlement::STATUSES); there is no expired status and no sweeper that
writes one. The customer-facing read surface filters on status: "active" and
nothing else.
So when the year ends, three things are true at once, and all three are consequences of that one missing column:
- The old entitlement is still active. Access continues.
- A second grant of the same product is a
409, not a renewal — the partial unique index from step 4 blocks a second active row for(account_id, product). - The audit trail still says the first grant.
entitlement_eventshas onegrantrow for the first payment and none for the second, because the second grant never happened.
This is a drift finding, recorded here rather than resolved. The pricing
page states each payment buys “a one-year
entitlement; renewal is a second link”, and moon/AGENTS-FIRST.md D8 decides
the prices as “annual one-time … a human confirms one payment and grants an
entitlement with a one-year expiry”. Neither the one-year expiry nor the
second grant is implemented. The Kit entitlement has the same shape. The
reconciliation is manual, so the missing expiry does not fail loudly — it is a
buyer who keeps access for a second year on one payment, which is a commercial
decision somebody has to make rather than a defect an operator can fix in
box.
What to do today, and it is the same shape as the revocation below:
# Grant the renewal, allowing for the 409 if the old row is still active:curl -s -o renewal.json -w 'HTTP %{http_code}\n' \ -X POST "$C/v1/operator/entitlements" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $STAFF_TOKEN" \ -d '{"account_id":"'"$ACC"'","product":"identity", "source":"reconciled_payment","reference":"stripe_payment_link_456"}'cat renewal.json201— the old row was not active (it was revoked, or this is the first grant of this product). Read the newid.409withduplicate: true— the old row is still active. You have a choice, and it is a commercial one, not a technical one:- Leave it. The buyer has access for another year and the trail records one grant. Simple, and honest about what the code does.
- Revoke and re-grant so the row and the trail name the second payment.
Revoking the old row (step 6) and then granting again produces a
revokeevent, agrantevent, and a new row whosereferencenames the second payment. This is the only way to get a secondgrantevent onto the account’s trail today. Revoking and re-granting is a decision about the commercial terms, not a procedure step — if you are not sure, leave it and ask the manager.
Step 6 — revocation
Section titled “Step 6 — revocation”POST /v1/operator/entitlements/:id/revokeThe :id is the entitlement row’s integer id. The whole action, quoted from
box/app/controllers/operator/entitlements_controller.rb:
def revoke actor = require_staff return if actor.nil?
entitlement = Entitlement.find_by(id: params[:id]) return render_not_found if entitlement.nil? return render json: { entitlement: serialize_entitlement(entitlement) } if entitlement.revoked?
Entitlement.transaction do entitlement.update!(status: "revoked", revoked_by: actor, revoked_at: Time.current) entitlement.entitlement_events.create!(action: "revoke", actor_user_id: actor) end
render json: { entitlement: serialize_entitlement(entitlement.reload) }endcurl -s -o revoke.json -w 'HTTP %{http_code}\n' \ -X POST "$C/v1/operator/entitlements/$ID/revoke" \ -H "Authorization: Bearer $STAFF_TOKEN"cat revoke.jsonIt is idempotent, and by an early return rather than by a retry. An
already-revoked row returns 200 with the row as it stands and writes no
second event — the revoke event is inside the transaction after the early
return, so a revoke you run twice leaves one revoke row and not two. That is
the correct direction: a duplicated revocation is a lie in the audit trail, and
this code cannot tell it.
An unknown id is a 404. render_not_found renders
{"error":{"code":"not_found","message":"not found"}} and, like every denial in
box, it is generic — it does not say whether the row ever existed.
When you would use it:
- The buyer wants out, or asks for a refund you have granted. The pair is refund on Stripe, revoke here. Do neither without the other.
- You granted the wrong product or the wrong account. This is the only remedy, and it is immediate.
- The buyer’s entitlement check starts failing with
403 version_not_grantedand they are entitled to a range the grant does not cover. Revoke, re-grant with the rightversion_range. - A dispute, or a chargeback. Revoke first, then settle the dispute. Stripe and box do not talk to each other, so nothing on the Stripe side does this for you.
What revoking does not do: it does not touch a box, a capability, an
installation or a release. boxes, email-providers, releases and
capabilities are separate tables with their own routes, and Entitlement
has no association to any of them. Revoking is a state change on one row plus
one audit event.
Step 7 — the audit trail
Section titled “Step 7 — the audit trail”Every grant and every revoke writes one row to entitlement_events, inside
the same transaction as the entitlement change. Entitlement.grant! in
box/app/models/entitlement.rb:
def self.grant!(account_id:, product: "kit", version_range: nil, source: nil, reference: nil, granted_by:) entitlement = new(account_id: account_id, product: product, version_range: version_range, source: source, reference: reference, granted_by: granted_by) transaction do entitlement.save! entitlement.entitlement_events.create!(action: "grant", actor_user_id: granted_by) end entitlementendThe comment above it states the invariant: “Every grant path — an operator confirming a payment, or a completed claim — funnels through here so the entitlement row and its audit event are one fact or neither is.” So a grant you can see always has an event behind it, and an event can never name a grant that did not happen.
The model, quoted from box/app/models/entitlement_event.rb, and the
reason it can be trusted:
class EntitlementEvent < ApplicationRecord ACTIONS = %w[grant revoke].freeze
belongs_to :entitlement
validates :action, presence: true, inclusion: { in: ACTIONS } validates :actor_user_id, presence: true
before_update :refuse_mutation before_destroy :refuse_mutation
private
def refuse_mutation raise ActiveRecord::ReadOnlyRecord, "#{self.class} rows are append-only" endendbefore_update and before_destroy both raise, so neither a console session
nor future code can rewrite an entitlement event. Entitlement’s association
is has_many :entitlement_events, dependent: :restrict_with_error, so you
cannot delete an entitlement that still has events against it either. The
columns, from box/db/schema.rb:
create_table "entitlement_events", force: :cascade do |t| t.bigint "entitlement_id", null: false t.string "action", null: false t.string "actor_user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["entitlement_id", "id"], name: "index_entitlement_events_on_entitlement_id_and_id" t.index ["entitlement_id"], name: "index_entitlement_events_on_entitlement_id"endaction is grant or revoke and is null: false. actor_user_id is
null: false — an event with no actor cannot be written, which is what
makes the trail attributable. The foreign key to entitlements is declared.
How to read them back
Section titled “How to read them back”[UNVERIFIED] — there is no endpoint that serves entitlement_events. I
looked for one and it is not there:
GET /v1/accounts/:account_id/audit-eventsis the audit route, and it servesaudit_release_eventsandaudit_approval_eventsonly — the release and approval history, joined into one newest-first timeline capped at 100. Its controller says so in its own header comment. It does not readentitlement_events.GET /v1/operator/entitlementsreturns entitlement rows, not events, and carriesgranted_by/revoked_bybut no event history.
So reading an entitlement grant back is a database query, not an API call.
entitlement_events is append-only, so this is a read you can run freely:
export BOX_DB='postgres://…' # box's own DATABASE_URL
psql "$BOX_DB" -c \ "select e.id, e.action, e.actor_user_id, e.created_at, n.product, n.status from entitlement_events e join entitlements n on n.id = e.entitlement_id where n.account_id = '$ACC' order by e.created_at desc;" id | action | actor_user_id | created_at | product | status----+--------+-------------------------------------+-------------------------------+-----------+--------- 87 | grant | 99999999-9999-4999-8999-999999999999 | 2026-10-10 11:04:22.118+00 | identity | active 55 | revoke | 99999999-9999-4999-8999-999999999999 | 2026-10-10 09:31:02.004+00 | identity | revoked 54 | grant | 99999999-9999-4999-8999-999999999999 | 2026-09-14 15:12:40.881+00 | identity | revokedThe join is the point. entitlement_events has no account_id; the event
belongs to the entitlement row, and the row carries the account. Joining
through entitlement_id is what scopes a trail to one buyer.
Two things to notice in that output, because they are what a reconciliation
review reads it for: the revoke before the grant means a renewal went
through the revoke-and-re-grant path from step 5, and the actor_user_id is
the same on all three rows means one operator did all of them. A different
actor_user_id mid-trail is a question worth asking.
Step 8 — the failure branches
Section titled “Step 8 — the failure branches”Identity is unreachable — box fails closed
Section titled “Identity is unreachable — box fails closed”Every staff and customer call box makes begins with an identity
round-trip, and box/app/controllers/application_controller.rb renders
one body for all of it. require_staff, quoted:
def require_staff token = bearer_token return render_unauthenticated if token.nil?
begin identity_client.staff_user_id!(token) rescue IdentityClient::Unauthenticated render_unauthenticated rescue IdentityClient::Forbidden render_forbidden("platform staff only") rescue IdentityClient::Misconfigured, IdentityClient::Unavailable render_unavailable endendand the body:
def render_unavailable render json: { error: { code: "unavailable", message: "authorization service unavailable" } }, status: :service_unavailable nilendSo the operator sees a 503 with a fixed body, and the grant does not
happen. That is correct and it is the safe direction. IdentityClient#get
rescues Net::OpenTimeout, Net::ReadTimeout, SocketError,
SystemCallError and IOError into Unavailable, and any status that is not
one of the handled ones raises it too — raise Unavailable, "identity answered #{res.status}". There is no path through this code that turns an identity
failure into an allow.
The three ways to get a 503, and they need different fixes:
| What you see | Cause | Fix |
|---|---|---|
503 with {"code":"unavailable","message":"authorization service unavailable"} |
identity is down, unreachable, or answered something unexpected | Restore identity. See a service is down |
| Same body, and identity is demonstrably up | BOX_IDENTITY_URL is unset — IdentityClient raises Misconfigured from raise Misconfigured, "BOX_IDENTITY_URL is not set" if @base_url.empty? and the controller maps it to the same 503 |
Set it on box and roll. It is read at construction, so a restart is required |
401 {"code":"unauthenticated","message":"valid bearer token required"} |
your token is missing, malformed, or expired | Get a fresh staff session |
A 503 that persists while identity answers curl fine is a base-URL or
network problem on box, not a staff problem — the message is
deliberately generic and says nothing about which. The underlying error class
is logged and never returned, so the reason is in box’s logs
(identity unavailable: #{e.class}), not in the response.
Read the failure as “do not grant yet”, not as “retry harder”. A grant that somehow succeeded while identity was unreachable would be a grant with no verified actor, which is the one thing the D2 procedure is built to prevent.
The buyer paid but has no account
Section titled “The buyer paid but has no account”This is the branch that takes the longest, and it is not an operator task you
can finish at the endpoint. There is no endpoint that creates an account on
somebody’s behalf — POST /v1/accounts in identity makes the caller the
owner, so an operator who created the account would own the buyer’s tenant,
and there is no version of that body that puts an account in somebody else’s
hands. Identity’s own words: “There is no owner field. The owner is whoever
is signed in.”
What to do, in order:
- Ask the buyer to register and create their account. They register a user, which creates their personal account automatically, and then create the tenant account they want the entitlement on. They send you the account id. Tenant provisioning is the procedure, and it is the same one a design partner goes through — nothing here is privileged.
- Grant against the account id they send (step 4), then verify with their own token (step 9).
- If they cannot register — no mail delivery, a broken recovery flow — you are into a service is down and not into this runbook. Do not work around identity by creating an account yourself.
Do not grant against the personal account. Registration creates a personal account with a slug derived from the user, and it is not the tenant. A paid unit granted to a personal account is granted to a person rather than to a company, and the account the buyer’s team uses will not see it.
The buyer wants a unit you do not sell
Section titled “The buyer wants a unit you do not sell”The four product values box accepts are kit, platform, identity
and courier. Anything else — billing, darkroom, gateway, muse, a
bundle that is not one of the three decided prices — is not purchasable
today. moon/AGENTS-FIRST.md D8 decided the price list and it has three
units on it.
The honest answer is not to invent a grant. A product outside the list is a
422:
rescue ActiveRecord::RecordInvalid => e return render_unprocessable(e.record)which renders {"error":{"code":"unprocessable","message":…}} — the message is
record.errors.full_messages.join(", "), so it names the field and the rule
from Entitlement’s own validations (product, inclusion: { in: PRODUCTS }).
An account id that is not a UUID is refused the same way, by
format: { with: UUID_RE }.
So tell them what exists, quote the price for it if they will take it, and
log the rest. A request for an unpackaged service is a product question for
the manager, and moon/AGENTS-FIRST.md is where it gets decided — not a grant
you improvise on a busy afternoon.
Step 9 — verify the buyer can see it
Section titled “Step 9 — verify the buyer can see it”Applying a grant is not evidence that it worked. The read is a separate step and it is the one that catches a wrong account id.
The buyer’s own read is member-scoped, so it doubles as the check that the account id was the right one:
# the buyer, with THEIR session token — not yourscurl -s "$C/v1/accounts/$ACC/entitlements" \ -H "Authorization: Bearer $BUYER_TOKEN"EntitlementsController#index (the customer-facing one, in
box/app/controllers/entitlements_controller.rb) filters to
product: "kit", status: "active" and serialises account_id, product,
status, source, reference, granted_at and revoked_at — no id, no
version_range.
{"entitlements":[{"account_id":"…","product":"kit","status":"active", "source":"claim","reference":null,"granted_at":"…","revoked_at":null}]}Then the check that actually proves the entitlement works, which is the
artifact.fetch check W4 added — a member asks whether the account holds the
product at a range:
curl -s -o check.json -w 'HTTP %{http_code}\n' \ -X POST "$C/v1/accounts/$ACC/artifact_fetches" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $BUYER_TOKEN" \ -d '{"artifact":"identity","version_range":"^1.0.0"}'cat check.jsonA 201 returns a single-use, minutes-lived capability token bound to exactly
that (account_id, artifact, version_range) triple. The two named refusals are
403 entitlement_required — no active entitlement for the product — and
403 version_not_granted — the grant does not cover the requested range, with
both ranges spelled out in the detail.
That is the end of the ten minutes. Payment confirmed, account found, entitlement granted and attributed, audit event written, and the buyer’s own token able to fetch what they paid for.
What does not happen automatically
Section titled “What does not happen automatically”| Step | Why not | What to do |
|---|---|---|
| The payment reaching cafaye | No billing service, no Stripe code in box, no webhook — D2’s whole decision | Read it off Stripe yourself (step 1) |
| A receipt to the buyer | courier can send mail and there is no template and no caller for this |
Send one yourself, out of band |
| The buyer’s account being found from their email | No user→account endpoint in identity, and the directory search returns no accounts (step 3) | Ask them, or read it off a prior grant |
| A one-year expiry | entitlements has no expires_at column and there is no sweeper |
Decide commercially whether to revoke at renewal (step 5) |
A renewal grant event |
The second grant is a 409 while the first row is active |
Revoke and re-grant if you want the trail to show it |
| Any event delivery on a bus | No service starts a publisher loop — identity’s only Publisher is a deliberate no-op |
Read the rows, or build a consumer |
A cafe command that grants |
No such subcommand exists; grant is an HTTP call on box’s operator surface | curl, with your staff token |
What not to do
Section titled “What not to do”- Do not grant before you have read the amount, the product, the status and
the email off Stripe. A refund is a manual revoke and a dispute is a
conversation; neither is something a
201can undo. - Do not create an account from the buyer’s Stripe email.
POST /v1/accountsmakes the caller the owner, so you would own the buyer’s tenant and the buyer would not be in it. - Do not grant
kitby accident. It is the default whenproductis omitted, andkitis what a claim buys. A paid unit isplatform,identityorcourier. - Do not retry a
409with a different body. It means the entitlement is already there. Changing theproductmid-retry grants a second thing. - Do not treat a
503as a permission problem. It is box failing closed because it could not reach identity. Fix identity, orBOX_IDENTITY_URL, and then grant. - Do not grant with a non-staff token. An account owner is not staff — no
membership implies global staff authority. The grant is refused with
403 {"code":"forbidden","message":"platform staff only"}and, correctly, nothing else. - Do not try to edit an entitlement event. The model raises
ActiveRecord::ReadOnlyRecordon update and on destroy, anddependent: :restrict_with_errorstops you deleting the parent either. - Do not read the buyer’s entitlement through the customer route and conclude
it failed. That route is hard-coded to
product: "kit", so a paid unit is invisible there. - Do not accept a product outside the four. A
422is the correct answer for something we do not sell, and the request is a manager decision, not a grant.
See also
Section titled “See also”- Pricing — what the four prices are and what the entitlement is supposed to buy; the one-year expiry it describes is not implemented.
- Tenant provisioning — the account the buyer needs before any of this works.
- Billing webhooks failing — the other Stripe mechanism, which is not this one and does not run in launch scope.
- Rotating secrets — the Stripe credentials that do exist, and the ones that cannot be rotated.
- A service is down — when the
503is identity’s and not box’s. - Topology — the ports, environment variables and HTTP surfaces this runbook calls.