Leads
plym ships a webhook which can be used by template authors to collect form data without running an external backend.
Updated Aug 04, 2026
On this page
POST /api/collect is a webhook that accepts any JSON object, stores it verbatim, and stamps it with the client IP address and the user agent. It needs no authentication and no schema registration: whatever keys you send become the columns of the Leads screen.
Send a submission
curl -X POST http://localhost:9173/api/collect \
-H 'Content-Type: application/json' \
-d '{
"email": "ada@example.com",
"name": "Ada Lovelace",
"message": "Interested in plym Cloud.",
"source": "pricing-page"
}'
{"id": 42, "created_at": "2026-07-30T09:14:22.187431Z"}
The response is a receipt and the status is 201. Post the form and discard the body — there is nothing else to read.
Customise the fields
The payload has no fixed schema, so the fields are whatever your form sends. Add or remove one and the next submission carries the change.
Serve the form from the same origin as plym and post to the relative path. plym sends no CORS headers, so a fetch from another origin is blocked by the browser.
<form id="contact">
<input name="email" type="email" required>
<input name="company">
<select name="plan_interest">
<option value="self-hosted">Self-hosted</option>
<option value="cloud">plym Cloud</option>
</select>
<button type="submit">Send</button>
</form>
<script>
document.getElementById('contact').addEventListener('submit', async (event) => {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
body.source = location.pathname;
await fetch('/api/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
event.target.reset();
});
</script>
The Leads table builds its columns from the union of keys across the submissions on screen, in the order it first meets them. A row saved before you added a field shows — in that column. Header labels come from the key: plan_interest becomes Plan interest.
What plym stores
| Field | Source | Notes |
|---|---|---|
payload |
Request body | Any JSON object. Nested objects and arrays are kept as sent and shown as JSON text. |
client_addr |
First entry of X-Forwarded-For, otherwise the connecting peer |
Stored as null when that value is not a valid IP address |
user_agent |
User-Agent header |
Stored and returned by the API. It is not a column on the Leads screen. |
created_at |
Server clock | UTC |
Behind a reverse proxy, the recorded address is whatever the proxy puts in X-Forwarded-For. The bundled Caddy sets it to the connecting client and discards any value the client sent. plym reads the first entry of that header and does not fall back to the peer address when the entry is unparseable, so a malformed header stores null rather than a wrong address.
Read leads in the admin
Leads is administrator-only. An editor who opens it is redirected to the dashboard.
Rows arrive newest first, 20 at a time, with Load more for the rest. Five controls sit above the table.
- Filter — a "contains" match per column. The badge counts the active filters.
- Sort — click a column header to cycle ascending, descending, off.
- Search — one query across every column at once.
- Export as CSV — downloads
leads-2026-07-30.csv. - Open in Google Sheets — copies the same rows as tab-separated text and opens a blank sheet to paste into.
Both exports write the filtered and sorted view, and only the rows already loaded. Click Load more until it disappears before you export, or the file is a partial table.
Read leads over the API
curl -H "Authorization: Bearer $PLYM_TOKEN" \
'http://localhost:9173/api/submissions?page=1&page_size=20'
{
"items": [
{
"id": 42,
"payload": {
"email": "ada@example.com",
"name": "Ada Lovelace",
"message": "Interested in plym Cloud.",
"source": "pricing-page"
},
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"client_addr": "203.0.113.9",
"additional_ctx": null,
"created_at": "2026-07-30T09:14:22.187431Z"
}
],
"total": 1,
"page": 1,
"page_size": 20
}
The caller must be an administrator. A missing or expired token returns 401 auth.token_invalid; an editor's token returns 403 auth.insufficient_role.
Limits
| Limit | Value |
|---|---|
Authentication on /api/collect |
None. Anyone who can reach the URL can post to it. |
| Rate limiting | None. |
| Payload type | A JSON object. An array, string, or number returns 422. |
| Content type | application/json. A form-encoded body returns 422. |
| Cross-origin browser requests | Not supported. OPTIONS /api/collect returns 405 with no Access-Control-* headers. Post from the same origin, proxy the path through your own domain, or post server-side. |
Page size on GET /api/submissions |
20 by default, 100 maximum |