Event stream
Send every analytics and purchase event to a webhook or an S3-compatible bucket, or read them yourself from a pull feed.
Choose one of three delivery methods:
| Method | How it works | Best for |
|---|---|---|
| Webhook | Grovs posts batches of events to your HTTPS endpoint | Live reactions in your backend, CRM or messaging tools |
| S3-compatible bucket | Grovs writes compressed files to your bucket, one folder per day | Warehouses such as Snowflake, Databricks and BigQuery |
| Pull feed | Your job reads pages of events with a read-only token | Pipelines that want to control their own pace |
Set them up in Developers → Data Export, on the Destinations, Pull feed tokens and Exports tabs. For history from before you connected, or older than 7 days, use an export.
Before you start
- Plan. The event stream needs an active paid plan on Grovs Cloud, or a self-hosted Enterprise deployment.
- Role. Anyone on the project's team can see destinations and their status. Only an instance admin can create, change, pause, replay or delete a destination, and create or revoke feed tokens.
- Environment. Destinations and tokens belong to one project environment. Set up production and test separately.
- Capture start. Capture begins when a project gets its first destination or feed token. Earlier events are not in the stream. Use an export for them.
Guarantees
| Delivery | At least once. Deduplicate on the event id. |
| Order | Per device, across analytics and purchases. Events of different devices interleave. |
| Latency | About 15 seconds while a destination is busy. An idle destination checks again every minute. |
| Retention | Captured events are kept for 7 days. You can replay within that window. |
Each destination keeps a bookmark of how far it has delivered. It moves only past events your endpoint accepted, so delivery resumes where it stopped after a failure.
Add a webhook destination
Go to Developers → Data Export → Destinations and click Add destination.
Under Destination type, choose Webhook. Enter a Name and the Webhook URL.
If your endpoint needs authentication, click Add header, for example Authorization with your key. Header values are stored encrypted and never shown again.
Pick the event types to send. Under Advanced settings, set the Batch size (1 to 500 events per request, default 100) and Concurrency (1 to 16 parallel requests, default 4).
Click Create and enable. Copy the Signing secret. It starts with whsec_ and is shown only once.
Open the destination and click Send test event. Grovs sends one envelope with "type": "test".
If you lose the signing secret, delete the destination and create a new one.
Webhook requests
Each batch is one POST with an application/x-ndjson body: one event per line.
Content-Type: application/x-ndjson
User-Agent: Grovs-EventStream/1
X-Grovs-Destination: gk3x
X-Grovs-Batch: 2f1c…
X-Grovs-Signature: t=1789459200,v1=6c2a…- Any
2xxresponse accepts the whole batch. Anything else is retried later from the same bookmark, so make your endpoint idempotent onid. - Redirects are not followed. A
3xxcounts as a failure. - Each request must finish within 40 seconds.
- Concurrent requests always cover different devices, so per-device order holds at any concurrency.
- If your endpoint takes one event per call, set the batch size to 1 and raise the concurrency.
Verify the signature
v1 is HMAC-SHA256(signing_secret, "<t>.<raw body>") in hex. Compare it in constant time and reject old timestamps.
// Node.js
const [, t, v1] = req.get("X-Grovs-Signature").match(/t=(\d+),v1=([0-9a-f]+)/);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));# Ruby
t, v1 = header.scan(/t=(\d+),v1=(\h+)/).first
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
ok = ActiveSupport::SecurityUtils.secure_compare(expected, v1)Webhook URL rules
The URL must be HTTPS. On Grovs Cloud, a URL resolving to a private or internal address is refused, both on save and on send. Grovs connects to the address it checked, so a DNS change cannot redirect deliveries.
Add an S3 destination
Click Add destination and choose S3-compatible bucket.
Enter the Bucket, the Region and an Object prefix (optional).
Enter an Access key ID and Secret access key. The key needs s3:PutObject on the prefix and nothing else. On Grovs Cloud, credentials are required.
For storage outside AWS, open Bucket options. Enter the provider's regional S3 API URL, without the bucket name, as the S3 endpoint URL (optional for AWS). Turn on Use path-style addressing if your provider needs the bucket in the URL path.
Click Create and enable, then open the destination and click Send test event to check the connection.
Any S3-compatible provider works, for example Hetzner. On Grovs Cloud, a custom endpoint must use HTTPS and resolve to a public address. Self-hosted deployments can also use an internal endpoint, or the deployment's own AWS credentials instead of keys.
Files in your bucket
Grovs writes one gzip-compressed NDJSON file per batch and event date:
<prefix>/project=<project id>/date=<YYYY-MM-DD>/<ms>-<event id>.ndjson.gz- The date is the event's own
occurred_at, so a late event lands in its own day's folder. - After it catches up, an S3 destination waits five minutes before it checks for new events.
- Batch size and concurrency do not apply to S3.
Choose event types
The event types are view, open, install, reinstall, app_open, time_spent, reactivation, user_referred, screen_view, custom and purchase. A new destination gets every type except time_spent and screen_view.
Removing an event type gives up its backlog. Turning it back on does not recover events from the period it was off. Use an export for that history.
Manage a destination
Open a destination from the Destinations tab for its delivery details and actions.
- Delivery lag: how far behind the analytics bookmark is.
- Pause delivery and Resume delivery: pausing keeps the bookmark. Resuming continues from it and clears the failure count.
- Replay events: moves the bookmark back to re-deliver events. Choose Last hour, Last 24 hours or a Custom time within the last 7 days. Your receiver must deduplicate. A paused destination stays paused after a replay.
- Send test event: works even while delivery is paused.
- Delete destination: stops delivery and removes the settings and bookmark.
When deliveries fail
A destination that keeps failing backs off, waiting up to 10 minutes between attempts. After 24 hours of failures it pauses itself and emails the instance admins. Only the response status is stored as the error, never the response body.
If a destination stays behind longer than the 7 days of retention, Grovs skips ahead and emails the range it skipped, at most once a day. Recover that range with an export to the destination.
Read the pull feed
Your server reads pages of events over HTTP and keeps its own cursor. Grovs stores no position.
1. Create a feed token
Go to Developers → Data Export → Pull feed tokens.
Click Create feed token and name it, for example Production warehouse. Use one token per integration so you can revoke them separately.
The token starts with est_ and is shown only once. Store it in your server's secret store.
A feed token reads one project and cannot change anything. Keep it out of browser and mobile app code.
2. Fetch your first page
export GROVS_API_URL='https://api.sqd.link'
export GROVS_PROJECT_ID='YOUR_PROJECT_ID'
export GROVS_FEED_TOKEN='YOUR_FEED_TOKEN'
curl --fail-with-body --get \
"$GROVS_API_URL/api/v1/projects/$GROVS_PROJECT_ID/event_stream" \
--header "Authorization: Bearer $GROVS_FEED_TOKEN" \
--data-urlencode 'limit=500'| Variable | What to use |
|---|---|
GROVS_API_URL | https://api.sqd.link on Grovs Cloud. On a self-hosted deployment, your API origin. |
GROVS_PROJECT_ID | The numeric ID of the project environment where you created the token. It is not the project.id hashid inside events. To find it, open your browser's network panel on Pull feed tokens, click Refresh tokens, and read the number in the request path /api/v1/projects/PROJECT_ID/event_stream_tokens. |
GROVS_FEED_TOKEN | The complete est_… token. |
Without after, the request starts 7 days back.
| Query parameter | Default | Description |
|---|---|---|
after | none | The next_after value from your last processed page. |
limit | 100 | 1 to 1000. Counts analytics and purchases together. |
event_types[] | all types | Repeatable, for example event_types[]=install&event_types[]=purchase. |
3. Process the page and save the cursor
{
"schema_version": 1,
"events": [
{ "schema_version": 1, "id": "example_event_id", "type": "install",
"occurred_at": "2026-09-16T10:03:11.412Z", "project": { "id": "PROJECT_HASHID", "test": false } }
],
"next_after": "OPAQUE_CURSOR_FROM_THE_API"
}Save next_after only after every event on the page is processed. Send it as after on the next request. If your process stops partway through a page, retry from the previous saved cursor and deduplicate by id.
- Do not decode or edit the cursor.
- Keep one cursor per project, integration and set of event types. If you add event types, start a new cursor: an existing one may already be past the events you now want.
- An empty
eventsarray means you have caught up. Save the returned cursor, wait, and poll again.
cursor = load_saved_cursor() // absent on the first run
repeat:
response = fetch_feed(after=cursor, limit=500)
if network failure or response is 503:
wait with exponential backoff and jitter
continue // keep the last saved cursor
if response is not successful:
stop and resolve the error
for event in response.events:
process_idempotently(event.id, event)
save_cursor_durably(response.next_after)
cursor = response.next_after
if response.events is empty:
wait before polling againReads are limited to 120 requests per minute per token. Over the limit, the feed answers 503. Back off and retry without moving the cursor.
Rotate or revoke a token
Create a new token for the same project environment.
Update your integration's secret and check it reads from its saved cursor.
On Pull feed tokens, click Revoke token on the old one and confirm.
Revoking takes effect immediately. Last used is accurate to the minute, not to the request.
When a project's last destination and last feed token are both gone, its captured stream data is deleted right away. Analytics history is not affected. Create the replacement before you revoke or delete the old one.
The event envelope
Every method delivers the same JSON object per event, schema_version: 1. New keys may appear in version 1. Renamed or removed keys come with a new version.
{
"schema_version": 1,
"id": "3f9a…",
"type": "install",
"name": null,
"occurred_at": "2026-09-15T10:03:11.412Z",
"project": { "id": "pQx7", "test": false },
"link": { "id": 8812, "path": "summer", "campaign_id": 41, "source": "email",
"medium": "newsletter", "campaign": "renewal", "ads_platform": null, "tags": ["q3"] },
"attribution": { "link_visitor_id": "V1…", "inviter_id": null, "sdk_generated": false },
"device": { "id": "D8…", "platform": "ios", "os": "iOS", "os_version": "18.6", "app_version": "3.11.0",
"build": "12687", "model": "iPhone16,2", "timezone": "America/Chicago", "language": "en" },
"user": { "id": "member-4471", "attributes": { "plan": "gold" } },
"geo": { "country": "US" },
"session_id": "s_…",
"screen": "Home",
"engagement_time": 0,
"properties": {},
"caused_by": null,
"purchase": null
}| Field | Notes |
|---|---|
id | The deduplication key. For purchases it changes with every correction. |
type | One of the event types above, or test. |
name | A custom event's name, or the store action of a purchase: buy, cancel, refund, refund_reversed. |
project.id, device.id, attribution.* | Hashids, as the dashboard shows them. project.test is true for the test environment. |
link.id, link.campaign_id | Integer IDs. link is null when the event has no link. |
user.id, user.attributes | The identifier and attributes your app set through the SDK. Use them to join with your CRM. |
geo | country only. A webhook or S3 destination with Include IP address and city also gets geo.ip and geo.city. The pull feed never includes them. |
caused_by | On user_referred, the ID of the install that produced it. |
purchase | On a purchase: transaction_id, original_transaction_id, product_id, purchase_type, store, quantity, currency, usd_price_cents, purchase_date, version. |
Purchase corrections
Stores correct purchases later, for example on a refund. Each correction arrives as a new event with a new id and a newer purchase.version.
- Within a project, upsert purchases on (
purchase.transaction_id,name) and keep the record with the newestpurchase.version. nameis the store action, so abuyand arefundof the same transaction stay separate records.- Deduplicate repeated deliveries of the same event by
id.
Troubleshooting
| Status | Meaning | What to do |
|---|---|---|
400 | Malformed cursor, limit or date | Restore the last saved cursor. If it is lost, omit after to re-read the last 7 days and deduplicate by id. |
401 | Missing, expired or revoked credential | Check the token. Replace a revoked one. |
402 | The instance has no active paid plan | Check your subscription or self-hosted entitlement. |
403 | Not an admin, or the token belongs to another project | Check the project ID in the URL, including the environment. |
424 | The test event reached your endpoint and it refused | Fix your endpoint, then test again. |
503 | Rate limited, or delivery is paused platform-wide | Back off and retry from the last saved cursor. |
No events? Check that the token or destination and the URL match the project environment. If you just connected, trigger a new event and wait a moment. If your integration was offline for more than 7 days, recover the gap with an export, then resume.
For the pull feed endpoint reference, see Event stream API.