Appbricx
Docs
FeaturesPricingStart free

Get started

OverviewWhat Appbricx isQuickstart with a coding agentQuickstart with the builderCore concepts

Guides

Data and tablesAccess and rolesBusiness rulesSign-inCustom screensRealtime and topicsWorkflows, schedules and webhooksServer functionsAI in your appPaymentsFilesMessagesIntegrationsVerifyPublishing, domains and rollbackOperating your appExport and self-hostSelf-host install

Tutorials

Build a booking app with Claude CodeMulti-user todo listClient portalSlack on new sign-upTemplates

Reference

Reference homeFrontend SDKHTTP APIMCP toolsCLIIntegrations catalogContract formatAI key resolutionRun Appbricx locally

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 TeamAdmin block.
  • 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_logins adds one for Kite Logistics).
  • project and invoice have "owner_refs": ["client_id"]: a row is a client's own when client_id points at their row.
  • "read": "own" with create, update and delete off makes the client side read only.
  • payment_event is 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

  1. Apply, then sign in as each client demo login: Northwind sees two projects and one invoice, Kite one and one.
  2. Run verify. The role matrix proves clients can't create or change projects or invoices, and that signed-out visitors read nothing private.
  3. Deploy with a subdomain, then add your domain (portal.youragency.com).

Gotchas

  • One login per client row. Each client row 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_event has no seed rows; it fills when the provider calls.

Related

  • Access and roles
  • Workflows, schedules and webhooks
  • Templates
PreviousMulti-user todo listNextSlack on new sign-up
Appbricx

The backend, runtime and hosting for apps your coding agent builds. On our cloud, in yours, or on your own servers.

Features

Coding agents and MCPVerify testingBuilder consolePostgres databaseData APIRealtimeRow-level securitySign-in and authPaymentsWorkflows and jobsAI in your appIntegrationsBackups and rollbackHosting and domainsSelf-host and exportAll features

Product

ShowcasePricingFor coding agentsTemplatesPrivate cloudBring your own AI keyFAQ

Compare

Appbricx vs SupabaseLovable alternativeBolt alternativeFor freelancersFor agencies

Resources

DocumentationReferenceBlogSupportContact

© 2026 Appbricx. All rights reserved.

TermsPrivacyCookiesAcceptable Use