Conversion API
Send verified conversion events from your backend to Lapis and attribute them to the original ChatGPT ad click.
The Conversion API sends verified conversion events from your backend to Lapis and attributes them to the original ChatGPT ad click. Use it whenever the final conversion happens somewhere the browser pixel cannot see it: inside your app, your checkout, your CRM, or any other backend system.
1. Prerequisites
The Conversion API attributes each event using a Lapis click ID that was captured on the ad landing page. Set up this chain first:
- Install the Lapis Pixel on the landing page.
- Make sure your ad URLs include
utm_source=chatgpt(oropenai). - Read the click ID from the
lapis_sidcookie and pass it into your app, signup, or checkout flow aslapis_click_id. - Store
lapis_click_idon the user, lead, checkout, account, or order record. - When the conversion is confirmed server-side, POST it to Lapis (see below).
If Lapis manages your landing page, you skip the cookie step: the lapis_click_id is handed to your app in the destination URL instead. Steps 4 and 5 stay exactly the same.
2. Authentication
Authenticate every request with a Lapis Conversion API key in the Authorization header:
Authorization: Bearer sk_live_...
Conversion API keys are different from public pixel keys. Public pixel keys start with pk_ and are safe to embed in a browser. Conversion API keys start with sk_live_ and must live only on your backend.
Tip: Conversion API events count as verified precisely because they are authenticated with a backend-only secret key. Keep that key out of all browser and mobile code.
3. Endpoint
POST https://api.trylapis.com/api/v1/pixel/server-events
Authorization: Bearer sk_live_...
Content-Type: application/json
Every request body must include an event_id. Lapis uses that value to handle retries safely, so the same conversion is never counted twice.
4. Request fields
| Field | Required | Type | Description | Example |
|---|---|---|---|---|
event_type |
Required | string | Client-defined event name. Must be lowercase snake_case. | signup_completed |
lapis_click_id |
Required | string | The Lapis click/session ID captured from the landing page. | mpf0phjdj7jpyqf4h |
event_id |
Required | string | Stable, unique ID for this event. Used to deduplicate retries. | signup_completed:respan_user_123 |
occurred_at |
Optional | datetime | When the event happened in your system. Use ISO 8601. | 2026-06-17T18:00:00Z |
external_user_id |
Optional | string | Your internal user ID. Lapis stores a hash, not the raw value. | respan_user_123 |
value |
Optional | number | Event value in dollars. Use 0 or omit for non-revenue events. | 99.00 |
currency |
Optional | string | Three-letter currency code. Defaults to USD. | USD |
properties |
Optional | object | Non-PII metadata for debugging and reporting. Must be JSON serializable, up to 32 KB. | {"plan":"team"} |
Example request body
{
"event_id": "signup_completed:respan_user_123",
"event_type": "signup_completed",
"lapis_click_id": "mpf0phjdj7jpyqf4h",
"occurred_at": "2026-06-17T18:00:00Z",
"external_user_id": "respan_user_123",
"value": 0,
"currency": "USD",
"properties": {
"plan": "free",
"source": "platform.respan.ai"
}
}
Lapis also accepts an OpenAI-style batch ({"events": [...]} with id, type, timestamp_ms, and data) and normalizes it automatically, including validate_only support. So if you already send server events to OpenAI, you can integrate by duplicating the call and adding lapis_click_id.
5. Responses
Matched event
{
"accepted": true,
"matched": true,
"duplicate": false,
"event_id": "signup_completed:respan_user_123",
"event_type": "signup_completed",
"session_id": "mpf0phjdj7jpyqf4h",
"campaign": "Respan_Observability_14cluster",
"ad_group": "RSPN_QualityReview_1410",
"ad_id": "a_clearer_ai_review_queue"
}
Accepted but unmatched
{
"accepted": true,
"matched": false,
"duplicate": false,
"event_id": "signup_completed:respan_user_123",
"event_type": "signup_completed",
"reason": "unknown_lapis_click_id"
}
Status codes
| Status | Meaning | What to do |
|---|---|---|
200 |
The event matched an ad click session, or was a duplicate of an already accepted event. | Treat as success. |
202 |
The event was accepted, but Lapis could not match the click ID to an active attribution session. | Treat as delivered, then inspect the reason field. |
401 |
Missing or invalid Conversion API key. | Check the Authorization header and key value. |
403 |
The click ID belongs outside this Conversion API key scope. | Use the key scoped to the same public pixel key. |
422 |
Invalid payload, event name, or missing event_id. |
Fix the request body before retrying. |
413 |
The properties payload is too large. |
Send fewer properties. |
6. Idempotency
Generate a stable event_id for each event. If your backend retries the same request, Lapis returns the original attribution result with duplicate: true and does not count the event again.
Build the ID from the business object it represents:
signup_completed:<your_user_id>
subscription_started:<your_subscription_id>
purchase_completed:<your_order_id>
Warning: Do not generate a new random ID on every retry. Random retry IDs defeat deduplication and can overcount your conversions.
7. Examples
cURL
curl https://api.trylapis.com/api/v1/pixel/server-events \
-X POST \
-H "Authorization: Bearer $LAPIS_SERVER_EVENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_id": "signup_completed:respan_user_123",
"event_type": "signup_completed",
"lapis_click_id": "mpf0phjdj7jpyqf4h",
"external_user_id": "respan_user_123",
"properties": {
"plan": "free"
}
}'
Node.js backend
type LapisConversionEvent = {
event_type: string;
lapis_click_id: string;
event_id: string;
external_user_id?: string;
value?: number;
currency?: string;
properties?: Record<string, unknown>;
};
export async function sendLapisConversionEvent(event: LapisConversionEvent) {
const response = await fetch(
"https://api.trylapis.com/api/v1/pixel/server-events",
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LAPIS_SERVER_EVENT_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(event),
}
);
const result = await response.json();
if (!response.ok) {
throw new Error(`Lapis Conversion API event failed: ${response.status}`);
}
return result;
}
Signup completed
await sendLapisConversionEvent({
event_id: `signup_completed:${user.id}`,
event_type: "signup_completed",
lapis_click_id: user.lapisClickId,
external_user_id: user.id,
value: 0,
currency: "USD",
properties: {
plan: user.plan,
source: "platform.respan.ai",
},
});
For business-specific event choices and payloads, see the event guides.
8. Security
- Store
sk_live_keys in a backend secret manager or environment variable. - Never send Conversion API keys to browser, mobile, or any third-party frontend code.
- Use separate keys per environment, and rotate a key immediately if it is exposed.
- Prefer keys scoped to a specific public pixel key when you have multiple websites or clients.
- Do not put emails, names, phone numbers, or other raw PII in
properties.
FAQ
Can I send arbitrary event types?
Yes. Event names are client-defined, as long as they use lowercase snake_case. For example: signup_completed, workspace_created, or subscription_started.
Does every event count as a conversion?
No. Lapis stores all valid events, but only the event types you configure as conversions update your conversion aggregates.
What if the click ID is unknown?
Lapis returns 202 with matched: false and a reason such as unknown_lapis_click_id. The event is accepted as delivered, but it is not attributed to a campaign.
Should Lapis or my backend generate event IDs?
Your backend should, because it knows the stable business object behind the event: the user ID, order ID, subscription ID, or checkout session ID.
Can I call this endpoint from client-side code?
No. The endpoint uses a secret key, so call it only from trusted server-side code.