Server-side events
Send events from your backend with the secret key, and the ingest limits that apply.
Some events shouldn’t come from a browser. Payments that settle asynchronously, subscription renewals, background jobs, anything a user can’t be present for. Those go through the server ingest endpoint with your secret key.
Public key vs secret key
| Public key | Secret key | |
|---|---|---|
| Format | lp_pub_ + 22 base58 chars | lp_sec_ + 43 base58 chars |
| Purpose | Browser SDK init() | Server-side ingest |
| Exposure | Designed to ship in client code — safe and expected | Never in browser code. Read it from an environment variable |
| Storage | Stored plaintext, viewable any time in Settings → Install | Only a hash is stored — shown exactly once, at project creation or key rotation |
Because only a hash of the secret key is stored, LaunchPulse cannot show it to you again. If you lose it, rotate the key in Project settings — which invalidates the old one — and store the new value immediately.
Use the environment variable name LAUNCHPULSE_SECRET. That’s the name the app’s AI-agent prompt tells coding agents to use, so sticking to it means an agent wiring up your integration will find it.
The endpoint
POST https://collector.launchpulse.dev/v1/events
Authorization: Bearer lp_sec_...
Content-Type: application/json
One event per request.
curl -X POST https://collector.launchpulse.dev/v1/events \
-H "Authorization: Bearer $LAUNCHPULSE_SECRET" \
-H "Content-Type: application/json" \
-d '{
"event": "subscription_paid",
"user_id": "user_8f21c",
"properties": {
"$revenue": 49.99,
"$currency": "USD",
"plan": "annual"
}
}'
Node, with fetch:
async function sendToLaunchPulse(event) {
const res = await fetch("https://collector.launchpulse.dev/v1/events", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LAUNCHPULSE_SECRET}`,
"Content-Type": "application/json",
},
body: JSON.stringify(event),
});
if (!res.ok) {
console.error("LaunchPulse ingest failed", res.status);
}
}
await sendToLaunchPulse({
event: "trial_converted",
user_id: user.id,
properties: { plan: "pro", seats: 3 },
});
Use the same user_id your browser code passes to identify(). Same id in both places means one actor. Different ids mean the same person counted twice.
Browser endpoint, for comparison
The SDK posts batches to a different endpoint with the public key in the body:
POST https://collector.launchpulse.dev/e
{ "k": "lp_pub_...", "events": [ ... ] }
You don’t normally call that yourself — that’s what the SDK does. Details in the SDK reference.
ingest_source
Events sent from the server are stamped ingest_source: 'server'. Browser events are 'browser'. You can filter on it in the events log, which is useful when you’re checking whether an integration is actually firing.
Limits
| Limit | Value |
|---|---|
| Request body size | 256 KB |
| Max events per request | 500 (else HTTP 413) |
| Rate limit | 6000/min per public key, 6000/min per IP (else HTTP 429) |
Property limits are the same as the browser SDK — 64 properties, 64-character keys, 1024-character string values, depth 3, 32 entries per array or object, 8192 bytes serialized. Violating any one of them rejects the event. The full table is in the SDK reference.
$revenue and $currency work exactly as they do in the browser. Any other $-prefixed property key rejects the event. See Revenue tracking.
Two behaviors that will confuse you if you don’t know them
A 2xx does not mean your key works
Unknown, revoked and paused keys all receive a uniform 2xx response. The API deliberately does not reveal whether a key exists or what state it’s in — that’s an anti-enumeration measure, so nobody can probe your keys by watching status codes.
The consequence for you: a 200 does not prove your key is valid. If you’re wiring up an integration and want confirmation, use the test screen during onboarding, or check the events log. Data appearing in the log is the only proof.
Paused projects behave the same way — events are accepted and dropped.
Timestamps are clamped
If you send a client timestamp, it is clamped to the window [server receive time − 5 minutes, server receive time]. The server’s receive time is authoritative.
This means you cannot backfill history through this endpoint, and you cannot send events dated in the future. A queued job that retries an hour later will land at the retry time, not the original time. If exact timing matters, send events as they happen rather than batching them into a nightly run.
When to send server-side
- Revenue. Payment settles on your backend, not on a thank-you page a user can reload. See Revenue tracking.
- Renewals and churn. No browser involved at all.
- Anything a user shouldn’t be able to fake. Browser events come from a client you don’t control.
- Events from background jobs — an export finishing, an import completing, a scheduled report going out.
Keep pageviews and in-app interactions in the browser. The SDK already handles identity, sessions and attribution there, and reproducing that server-side is work with no payoff.
Keeping the key safe
- Environment variable, never a committed file.
LAUNCHPULSE_SECRET. - Never in a bundle, a client component, or anything with
NEXT_PUBLIC_on it. - Rotate it in Project settings if it ever lands somewhere public. Rotation invalidates the old key immediately.
More in Security.