Overview
Fire a LifecycleEventKind for one of your users. SeggWat looks up every Live survey whose Backend trigger subscribes to that event, creates one SurveyInvitation per match, and returns the magic-link URLs in the response. Your backend is responsible for delivering those links to the user (email, SMS, in-product message, push, whatever fits) — SeggWat itself only renders the in-app widget; it does not send emails on your behalf.
Use this for "right-now" moments your backend already detects: a trial just expired, a subscription was cancelled, onboarding wrapped up. For future events (like a heads-up N days before trial expiry), use POST /api/v1/users/upsert with trial_ends_at instead — that one schedules; this one fires now.
For an end-to-end walkthrough of when to use this vs. the frontend-trigger path, see the Backend-triggered Surveys guide. This page is the formal API reference.
Authentication
Authorization: Bearer <oat_token> or X-API-Key: <oat_token>.
Use an Organization Access Token. This endpoint runs from your backend, never the browser — origin headers don't apply to server-fired events, so API key auth is the only way to prove the request came from your org.
Request
POST /api/v1/lifecycle/dispatch
project_idstringrequiredThe SeggWat project to fire this event against. Must belong to the API key's organization.
Example: "507f1f77bcf86cd799439011"
eventstring | objectrequiredWhich lifecycle event to fire. One of the predefined snake-case strings, or {"custom": "your_event_name"} for a customer-defined event.
| Dashboard checkbox | API value |
|---|---|
| Trial expired | "trial_expired" |
| Trial ending soon | "trial_ending_soon" |
| Trial cancelled | "trial_cancelled" |
| Subscription cancelled | "subscription_cancelled" |
| Subscription downgraded | "subscription_downgraded" |
| Onboarding completed | "onboarding_completed" |
| First feedback submitted | "first_feedback_submitted" |
| Custom event | {"custom": "payment_failed"} |
Example: "subscription_cancelled"
"trial_ending_soon" is dispatchable for completeness, but is normally scheduled implicitly via POST /api/v1/users/upsert when you set trial_ends_at. Don't fire it manually unless you've turned off the scheduled path.
user_refstringrequiredYour stable identifier for the user this event applies to — the same user_ref you pass to users/upsert and SeggwatSurvey.setUser(...). Echoes back on SurveyResponse.submitted_by when the resulting invitation is completed, and on each entry of created_invitations in the response.
Example: "user_abc123"
Example requests
Right-now event with a predefined kind:
curl -X POST https://seggwat.com/api/v1/lifecycle/dispatch \
-H "Authorization: Bearer $SEGGWAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "507f1f77bcf86cd799439011",
"user_ref": "user_abc123",
"event": "subscription_cancelled"
}'Custom event name:
curl -X POST https://seggwat.com/api/v1/lifecycle/dispatch \
-H "Authorization: Bearer $SEGGWAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "507f1f77bcf86cd799439011",
"user_ref": "user_abc123",
"event": { "custom": "payment_failed" }
}'Pair this with a survey whose trigger lists {"custom": "payment_failed"} in the dashboard — the dispatcher matches by exact event equality.
Response
matched_surveysintegerNumber of Live surveys whose Backend event trigger matched the fired event. Always ≤ the number of surveys you've configured for that event.
created_countintegerNumber of new SurveyInvitation rows actually persisted. Equals created_invitations.length. Lower than matched_surveys when some matches were skipped by cooldown, sample rate, or errored.
suppressed_by_cooldown_countintegerMatched surveys skipped because the per-survey cooldown window for this user_ref is still open. Most commonly the same event fired twice in quick succession — safe to ignore as long as it's not consistently equal to matched_surveys.
suppressed_by_sample_countintegerMatched surveys skipped because the per-survey sample_rate roll missed. Independent of cooldown — a sample miss doesn't write an invitation, so a re-fired event gets a fresh roll.
errored_countintegerMatched surveys that errored mid-dispatch (DB write fail, etc.). A non-zero value here is worth alerting on customer-side — the call succeeded but at least one invitation was lost.
created_invitationsarrayOne entry per invitation actually created. Use the magic_link_url to deliver the survey to your user via your own notification pipeline.
survey_id(string) — id of the parent survey.user_ref(string) — your user identifier, echoed back so the same field maps 1-to-1 to the dispatch request.magic_link_url(string) — direct link to the SeggWat-hosted respond page. The token is single-survey and uniquely identifies this invitation — treat it like a password and only ship it to the targeted user.expires_at(ISO 8601 timestamp) — after this point the link 410s. Default lifetime is 30 days; send well before then.
Example response
{
"matched_surveys": 2,
"created_count": 2,
"suppressed_by_cooldown_count": 0,
"suppressed_by_sample_count": 0,
"errored_count": 0,
"created_invitations": [
{
"survey_id": "65b1f00000000000000000aa",
"user_ref": "user_abc123",
"magic_link_url": "https://seggwat.com/surveys/abc...43chars",
"expires_at": "2026-06-26T11:22:00Z"
},
{
"survey_id": "65b1f00000000000000000bb",
"user_ref": "user_abc123",
"magic_link_url": "https://seggwat.com/surveys/def...43chars",
"expires_at": "2026-06-26T11:22:00Z"
}
]
}Status codes
| Code | Meaning |
|---|---|
200 |
Dispatch handled. Inspect the counts to see how many invitations were actually created. |
400 |
Validation failed (user_ref empty, body unparseable, unknown event variant). |
401 |
Missing or invalid API key. |
403 |
API key's org doesn't own the project. |
404 |
Project not found. |
A 200 response is not the same as "the survey fired." Always check created_count to confirm at least one invitation was persisted. A 200 with matched_surveys: 0 means the call succeeded but no Live survey is subscribed to that event yet — most often a stale dashboard config.
Delivering the magic link
The in-app widget surfaces pending invitations on the user's next session automatically — no extra work needed there. To reach users who aren't currently in the product, hand the magic_link_url to your existing notification stack:
# Example: relay the magic link via Postmark
import requests
dispatch = requests.post(
"https://seggwat.com/api/v1/lifecycle/dispatch",
headers={"Authorization": f"Bearer {SEGGWAT_API_KEY}"},
json={
"project_id": PROJECT_ID,
"user_ref": user.id,
"event": "subscription_cancelled",
},
).json()
for invitation in dispatch["created_invitations"]:
postmark.send_email(
From="feedback@yourapp.com",
To=user.email,
Subject="Quick favour before you go — 2 questions",
HtmlBody=render_template("survey_email.html", url=invitation["magic_link_url"]),
)This keeps you in full control of sender domain, deliverability, branding, unsubscribe handling, and the legal basis for the message.
When to call this
- At the moment your backend detects the event. Your trial-expiry cron, your Stripe/Polar webhook handler, your onboarding-completion code path. SeggWat does not automatically dispatch from billing webhooks — your code must.
- Once per event occurrence. The endpoint is not idempotent on its own; firing twice creates two invitations (unless the per-survey cooldown swallows the second). Dedupe at your caller if your detection code might re-emit.
Don't call this for
trial_ending_soon— useusers/upsertwithtrial_ends_at. SeggWat schedules and de-schedules for you, including handling trial extensions and cancellations.- Pageview-style triggers — use the frontend-trigger mode (URL pattern or
SeggwatSurvey.trigger("…")). Backend dispatch is overkill for "fire when the user lands on /pricing."
Related
- Backend-triggered Surveys guide — when to use backend triggers vs. the frontend trigger, with side-by-side examples.
POST /api/v1/users/upsert— register a user (and theirtrial_ends_at) before dispatching.- Survey widget — the in-app counterpart that surfaces pending invitations on the user's next visit.
