Send signup and order events from your app to your own dashboard with curl

By

Post signup and order events with curl to Events Logger, a self-hosted SQLite dashboard, and read them back in the project feed.

Self-hosted event tracking keeps a signup or an order in a database you run, on a machine you control.

Events Logger is that dashboard. Your app sends one JSON event over HTTP, and the event shows up in a feed. SQLite stores it in a local file. You don’t create an account for the dashboard, and it doesn’t ask for a cloud API key.

First-party analytics with SQLite is the pageview side of this idea: one events table, funnel queries, beacons from the browser. This tutorial is the other kind of event, the ones your own backend already knows about. A person registered. An order was placed.

A webhook is a POST some other service sends you when something happens, such as a payment. The call below is the same shape of request, sent by your app to a dashboard you host.

What you need

Events Logger runs on Node.js 18 or later, with npm. After purchasing it, connect GitHub in Polar and accept the invitation to the private repository. Clone that repository, or download the source from Releases and unzip it.

The interface still says Events Dashboard in some places. It is the same app.

Keep the installation on your machine, or behind an authenticated private gateway. The shipped dashboard has no login. It shows the global API key on the home page, and it has unauthenticated reading and dashboard mutation routes. The API expects that key when you create a project, push an event, or update an insight card. Don’t put this server on the public internet as it ships.

The examples use a made-up shop, Garden Shop, and the placeholder key and project id from the app page. Copy your own from the dashboard. Don’t commit the real key.

Run the dashboard and create a project

From the repository root, install dependencies, create the data directory, and start the server:

npm install
mkdir -p data
npm run dev

Open http://localhost:4321. On first startup the app creates data/events.db and its tables, and it generates one global API key. That key is on the home page. You’ll send it as a Bearer token.

Create a project named Garden Shop from the home page. Each project has its own feed, charts, insights, and settings.

Open that project’s Playground. It shows the exact HTTP call for this installation, which is where you read the project id. The id in the samples below, q4q8nb18qc2i, is the placeholder from the app page. Yours will be different. New categories are created as events arrive, so you don’t define orders or signups ahead of time.

Send a signup and an order with curl

The base URL is http://localhost:4321 when you run it locally. Change it if you later put the dashboard on a private server.

A signup only needs the three required fields, project, category, and title. This is the sample signup from the app’s CLI, sent as the HTTP call:

curl --fail-with-body -X POST http://localhost:4321/api/events \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ev_your-key-here" \
  -d '{
    "project": "q4q8nb18qc2i",
    "category": "signups",
    "title": "User Registered"
  }'

Replace ev_your-key-here with the key on the home page, and replace the project id with the one in the Playground. The API returns the stored event as JSON. The status code is 201.

An order can carry a description. Bold text and links work in that field. This is the sample order from the app page, Maria placing order 1234 in the example shop:

curl --fail-with-body -X POST http://localhost:4321/api/events \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ev_your-key-here" \
  -d '{
    "project": "q4q8nb18qc2i",
    "category": "orders",
    "title": "Order Placed",
    "description": "Order **#1234** placed by **Maria**"
  }'

You should now have both events in the Garden Shop feed. The feed is chronological, and it refreshes while you leave it open. Filter by signups or orders, or search the title and the description.

Optional fields sit next to those three. tags is a set of key-value pairs you can search. url turns the title into a link, for example to that order in your own app. user_id attaches a user id. icon is an emoji. notify asks for a browser notification. Leave them out until you need them.

Send a signup or order to your own dashboardRun Events Logger, create a project, copy the API key and project id, and POST the event with curl. A 201 means the feed has it. A 401 means the key is wrong.Yes401npm run devCreate Garden ShopCopy key and projectidcurl POST/api/eventsStatus 201?Event in the feedCheck the API key

The same push exists as a CLI command, run from the repository root:

node cli/index.js push \
  --api-key ev_your-key-here \
  --project q4q8nb18qc2i \
  --category signups \
  --title "User Registered"

The key is an argument on this command, so it can show up in a log or in a shared terminal recording. The backend example below reads the key from the environment instead. node cli/index.js --help lists the rest of the options.

Read the event back

You can fetch the events over HTTP too. Read-only calls don’t send the Bearer token:

curl "http://localhost:4321/api/events?project=q4q8nb18qc2i&category=orders&limit=20"

The response includes the events and a cursor for the next page. Point project at your real project id. category=orders keeps the signups out of this page. The search parameter searches titles, descriptions, and tags. Use the returned nextCursor to fetch another page; a null cursor means there is no next page.

Charts on the project shows how often each category arrives over time.

Send the same event from your backend

Once the curl returns 201, the app should send the event itself. Copy the API key into the server environment as EVENTS_API_KEY, and the Garden Shop project id as EVENTS_PROJECT_ID. Keep both out of the browser bundle. This is the Node example from the docs, using the built-in fetch:

const response = await fetch('http://localhost:4321/api/events', {
  method: 'POST',
  signal: AbortSignal.timeout(5000),
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.EVENTS_API_KEY}`,
  },
  body: JSON.stringify({
    project: process.env.EVENTS_PROJECT_ID,
    category: 'orders',
    title: 'Order packed',
    tags: { order: '1001' },
  }),
})

if (!response.ok) throw new Error(`Event request failed: ${response.status}`)

Order packed and order 1001 are the docs’ sample, a later moment in the same kind of shop. Change the URL if the dashboard is on a private server. The repository also includes examples for Python, PHP, Ruby, and Go.

A 201 means the row is in SQLite. Open the Garden Shop feed and you should see Order packed, with the order tag you can search for.

Follow an order through the feed

Use the order number as a tag so you can find related events without putting a customer’s email address in every row. For example, your backend can send Order placed, Order packed and Order dispatched with the same order tag.

Search the example order with URL-encoded parameters:

curl --fail-with-body --get http://localhost:4321/api/events \
  --data-urlencode 'project=q4q8nb18qc2i' \
  --data-urlencode 'category=orders' \
  --data-urlencode 'search=1001' \
  --data-urlencode 'limit=20'

The top-level response has events and nextCursor. If nextCursor is 48, add --data-urlencode 'cursor=48' to the next request. Keep the same filters. Events arrive newest first; the cursor fetches older rows rather than asking for a page number that shifts whenever another order arrives.

The search is a text match, including the serialized tags. Searching for 1001 can also match that number inside a longer description. Use the feed for investigation; do not treat this search as a database constraint proving that an order exists exactly once.

Decide what a failed delivery means

Send Order packed after your application has saved that state. If the dashboard is temporarily unavailable, the order is still packed. Do not roll back the business operation just because an observability request failed.

The backend example has a five-second timeout so a stopped dashboard does not hold the request open indefinitely. Catch a failure where your application calls it, and record enough information to investigate. If events must survive a restart, save a pending delivery in your application’s database and let a worker send it later.

The shipped ingestion endpoint does not deduplicate repeated POSTs. A timeout is ambiguous: the dashboard may have saved the row before the response was lost. Retrying can create a second event. An order tag helps you recognize those duplicates; it does not prevent them.

ResultWhat to check
201Row stored; read it back in the intended project
400Missing project, category or title
401Bearer token does not match the installation’s key
404The project ID does not exist on this installation
Network error or timeoutServer address, process and connectivity; check the feed before retrying

curl --fail-with-body keeps the error response visible and exits unsuccessfully for HTTP errors. Plain curl can print a 401 response and still exit successfully, which is easy to miss in a script.

If something looks wrong

If the POST returns 401, the Bearer token doesn’t match the global API key on the home page. Copy it again. The key is created the first time the app starts, and a new data/ directory gets its own key.

If the event lands in the wrong project, compare project in the JSON with the id shown in that project’s Playground. The placeholder q4q8nb18qc2i is not your id.

If SQLite can’t open the database, check that data/ exists and is writable, then start npm run dev again from the repository root. The file it creates is data/events.db.

If the feed stays empty while curl printed JSON, you are looking at another project, or the server you posted to is not the one in the browser. Use the URL Astro printed, and the project you created on that server.

If you later move the dashboard off your machine, build with npm run build. The standalone Node entry is dist/server/entry.mjs. A hosted copy needs HTTPS, a persistent data/ directory, and an authentication gateway in front of every dashboard and API route. The ingestion key is not that gateway.

Get Events Logger

Track signups, orders, deploys and errors in your own SQLite dashboard. Includes an HTTP API, CLI, charts and full MIT source.

See Events Logger → · Read the docs · $20 including tax, with full source code.

More tutorials for my apps →

Want me to talk about your product? You can sponsor this site.