plym Docs
    plym.io

    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

    Frequently asked questions

    What if a nested object is posted to the endpoint?
    They are stored exactly as sent, but the table renders the whole value as JSON text in one cell and exports it the same way. Flatten to top-level keys if you want columns you can filter and sort.
    What is `additional_ctx`?
    A second JSON column on the row, `null` for everything created through `/api/collect`. When it holds keys, the Leads table adds a column for each one after the payload columns.