Tutorial: a client portal
Agencies and B2B tools need the same thing: every customer signs in and sees only their own projects and invoices, while your team sees everything. You will build that, mark invoices paid from a payment provider's webhook, send your team a weekly digest and invite colleagues.
What you will learn
- Per-customer isolation with
owner_refs(rows assigned to a customer). - An invite-only staff side with the
TeamAdminblock. - An inbound webhook that records a row, and a workflow that reacts to it.
1. Ask your agent
Use appbricx. Create "Portal": account managers run client accounts,
projects and invoices. Each client signs in and sees only their own
projects and invoices, read only. A payment provider will POST
{invoice_number, amount} to a webhook: record it and mark the invoice
paid when the amount covers it, then notify the managers. Every Monday
08:00 London, notify managers of new projects by status. Managers can
invite teammates. Seed two clients with projects and invoices.2. The contract
Checked on production with no errors:
{
"app": {
"name": "Portal",
"tagline": "Every client sees only their own work",
"brand_color": "#0F766E",
"nav_style": "sidebar",
"density": "compact"
},
"personas": [
{
"id": "client",
"label": "Client",
"description": "a customer account that follows its projects and pays invoices",
"kind": "consumer"
},
{
"id": "manager",
"label": "Account manager",
"description": "runs every client account",
"kind": "staff"
}
],
"coordinates": {
"primaryVerb": "manage",
"inventoryShape": "records",
"moneySurface": "subscription"
},
"entities": [
{
"name": "client",
"label_field": "company",
"fields": [
{
"name": "company",
"type": "text"
},
{
"name": "contact_email",
"type": "email"
},
{
"name": "plan",
"type": "enum",
"enum_values": [
"starter",
"growth",
"scale"
]
}
],
"access": {
"client": {
"read": "own",
"update": "own"
}
}
},
{
"name": "manager",
"label_field": "name",
"fields": [
{
"name": "name",
"type": "text"
}
]
},
{
"name": "project",
"label_field": "name",
"fields": [
{
"name": "name",
"type": "text"
},
{
"name": "client_id",
"type": "ref",
"ref": "client"
},
{
"name": "status",
"type": "enum",
"enum_values": [
"planned",
"active",
"done"
]
},
{
"name": "hours_used",
"type": "number",
"optional": true
}
],
"owner_refs": [
"client_id"
],
"access": {
"client": {
"read": "own",
"create": false,
"update": "none",
"delete": "none"
}
}
},
{
"name": "invoice",
"label_field": "number",
"fields": [
{
"name": "number",
"type": "text"
},
{
"name": "client_id",
"type": "ref",
"ref": "client"
},
{
"name": "amount",
"type": "number"
},
{
"name": "status",
"type": "enum",
"enum_values": [
"open",
"paid",
"void"
]
},
{
"name": "due_on",
"type": "date"
}
],
"owner_refs": [
"client_id"
],
"access": {
"client": {
"read": "own",
"create": false,
"update": "none",
"delete": "none"
}
}
},
{
"name": "payment_event",
"label_field": "invoice_number",
"fields": [
{
"name": "invoice_number",
"type": "text"
},
{
"name": "amount",
"type": "number"
},
{
"name": "provider_ref",
"type": "text",
"optional": true
}
],
"access": {
"client": {
"read": "none",
"create": false,
"update": "none",
"delete": "none"
}
}
}
],
"seeds": {
"manager": [
{
"id": "mg1",
"name": "Priya Shah"
}
],
"client": [
{
"id": "c1",
"company": "Northwind Studio",
"contact_email": "ops@northwind.example",
"plan": "growth"
},
{
"id": "c2",
"company": "Kite Logistics",
"contact_email": "it@kite.example",
"plan": "starter"
}
],
"project": [
{
"id": "p1",
"name": "Website rebuild",
"client_id": "c1",
"status": "active",
"hours_used": 42
},
{
"id": "p2",
"name": "Brand refresh",
"client_id": "c1",
"status": "done",
"hours_used": 18
},
{
"id": "p3",
"name": "Driver app",
"client_id": "c2",
"status": "planned",
"hours_used": 0
}
],
"invoice": [
{
"id": "i1",
"number": "INV-1001",
"client_id": "c1",
"amount": 4200,
"status": "open",
"due_on": "2026-10-30"
},
{
"id": "i2",
"number": "INV-1002",
"client_id": "c2",
"amount": 900,
"status": "open",
"due_on": "2026-11-05"
}
],
"payment_event": []
},
"demo_logins": [
{
"persona": "client",
"row": "c2"
}
],
"pages": [
{
"name": "my-projects",
"route": "/my-projects",
"title": "Projects",
"icon": "Folder",
"personas": [
"client"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Your projects",
"subtitle": "What we're working on for you."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "project",
"presentation": "cards",
"columns": [
"name",
"status",
"hours_used"
],
"emptyCopy": "Your first project starts soon."
}
}
]
}
},
{
"name": "my-invoices",
"route": "/my-invoices",
"title": "Invoices",
"icon": "FileText",
"personas": [
"client"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Invoices"
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "invoice",
"presentation": "table",
"columns": [
"number",
"amount",
"status",
"due_on"
],
"emptyCopy": "No invoices yet."
}
}
]
}
},
{
"name": "clients",
"route": "/clients",
"title": "Clients",
"icon": "Building2",
"personas": [
"manager"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Clients"
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "client",
"presentation": "table",
"columns": [
"company",
"contact_email",
"plan"
],
"searchField": "company",
"emptyCopy": "Add your first client."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "client",
"submitLabel": "Save client",
"fields": [
"company",
"contact_email",
"plan"
]
}
}
},
{
"name": "projects",
"route": "/projects",
"title": "All projects",
"icon": "LayoutGrid",
"personas": [
"manager"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "All projects"
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "project",
"presentation": "grouped",
"groupBy": "status",
"columns": [
"name",
"client_id",
"hours_used"
],
"emptyCopy": "No projects yet."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "project",
"submitLabel": "Save project",
"fields": [
"name",
"client_id",
"status",
"hours_used"
]
}
}
},
{
"name": "invoices",
"route": "/invoices",
"title": "All invoices",
"icon": "Wallet",
"personas": [
"manager"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "All invoices"
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "invoice",
"presentation": "table",
"columns": [
"number",
"client_id",
"amount",
"status",
"due_on"
],
"filters": [
{
"field": "status",
"label": "Status"
}
],
"emptyCopy": "No invoices yet."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "invoice",
"submitLabel": "Save invoice",
"fields": [
"number",
"client_id",
"amount",
"status",
"due_on"
]
}
}
},
{
"name": "payments",
"route": "/payments",
"title": "Payment events",
"icon": "CreditCard",
"personas": [
"manager"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Payment events",
"subtitle": "What the payment provider told us."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "payment_event",
"presentation": "rows",
"columns": [
"invoice_number",
"amount",
"provider_ref"
],
"emptyCopy": "No payments received yet."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "payment_event",
"submitLabel": "Record payment",
"fields": [
"invoice_number",
"amount",
"provider_ref"
]
}
}
},
{
"name": "team",
"route": "/team",
"title": "Team",
"icon": "Users",
"personas": [
"manager"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Team and access"
},
{
"kind": "block",
"block": "TeamAdmin",
"props": {
"inviteLabel": "Invite a person",
"emptyCopy": "Invite your account managers and give each client a login."
}
}
]
}
}
],
"workflows": [
{
"id": "payment-received",
"title": "Payment provider callback",
"on": {
"webhook": "payment-received"
},
"do": [
{
"action": "record",
"entity": "payment_event"
}
]
},
{
"id": "mark-invoice-paid",
"title": "Mark the invoice paid",
"on": {
"entity": "payment_event",
"event": "created"
},
"do": [
{
"action": "custom",
"code": "const invoices = await ctx.db.list('invoice');\nconst inv = invoices.find((i) => String(i.number) === String(row.invoice_number));\nif (!inv) { ctx.log(`no invoice ${String(row.invoice_number)}`); return; }\nif (Number(row.amount) < Number(inv.amount)) { ctx.log('partial payment, left open'); return; }\nawait ctx.db.update('invoice', String(inv.id), { status: 'paid' });\nctx.notify({ title: 'Invoice paid', body: `${String(inv.number)} is paid`, role: 'manager' });"
}
]
},
{
"id": "monday-hours",
"title": "Monday hours digest",
"on": {
"schedule": "weekly mon 08:00",
"entity": "project",
"tz": "Europe/London"
},
"do": [
{
"action": "notify",
"role": "manager",
"title": "Projects this week",
"body": "{{count}} new projects in {{period}}.\n{{by:status}}"
}
]
}
]
}How the isolation works
- Each client account is a row in
client, linked to a login (the first seed row gets the default demo login;demo_loginsadds one for Kite Logistics). projectandinvoicehave"owner_refs": ["client_id"]: a row is a client's own whenclient_idpoints at their row."read": "own"with create, update and delete off makes the client side read only.payment_eventis invisible to clients ("read": "none").- Managers are
staff, so they see everything. Only the first consumer persona (client) can sign up publicly; managers are invited from the Team page. Switch sign-up to invite only in Backend, Auth if clients should be invited too.
3. The webhook and the reaction
payment-received is hosted at POST /hooks/<project-id>/payment-receivedand turns the caller's JSON into a payment_event row. Then mark-invoice-paid runs on that new row:
const invoices = await ctx.db.list('invoice');
const inv = invoices.find((i) => String(i.number) === String(row.invoice_number));
if (!inv) { ctx.log(`no invoice ${String(row.invoice_number)}`); return; }
if (Number(row.amount) < Number(inv.amount)) { ctx.log('partial payment, left open'); return; }
await ctx.db.update('invoice', String(inv.id), { status: 'paid' });
ctx.notify({ title: 'Invoice paid', body: `${String(inv.number)} is paid`, role: 'manager' });Send a signed test call (copy the signing secret from Backend, API):
BODY='{"invoice_number":"INV-1001","amount":4200,"provider_ref":"test_1"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')
curl -sS -X POST "https://<your-app-host>/hooks/$PROJECT_ID/payment-received" \
-H "content-type: application/json" -H "x-appbricx-signature: $SIG" -d "$BODY"
# → { "ok": true, "runId": "…" }INV-1001 moves to paid, and managers get an "Invoice paid" notification.
4. Verify and deploy
- Apply, then sign in as each client demo login: Northwind sees two projects and one invoice, Kite one and one.
- Run verify. The role matrix proves clients can't create or change projects or invoices, and that signed-out visitors read nothing private.
- Deploy with a subdomain, then add your domain (
portal.youragency.com).
Gotchas
- One login per client row. Each
clientrow links to one account. For several people per customer, add a contact table that refs the client and make it the persona, or use organisations. - Webhook calls must be signed over the exact raw body; a re-serialised body fails the signature.
- Never trust the amount alone in real life. For Stripe, confirm the event with the Stripe API in a server function or use payments.
- Empty tables are fine. The check notes that
payment_eventhas no seed rows; it fills when the provider calls.