Docs
grovs.io Start for free

Event stream

Send every analytics and purchase event to a webhook or an S3-compatible bucket, or read them yourself from a pull feed.

Paid Grovs Cloud plans and self-hosted Enterprise

Choose one of three delivery methods:

MethodHow it worksBest for
WebhookGrovs posts batches of events to your HTTPS endpointLive reactions in your backend, CRM or messaging tools
S3-compatible bucketGrovs writes compressed files to your bucket, one folder per dayWarehouses such as Snowflake, Databricks and BigQuery
Pull feedYour job reads pages of events with a read-only tokenPipelines 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

DeliveryAt least once. Deduplicate on the event id.
OrderPer device, across analytics and purchases. Events of different devices interleave.
LatencyAbout 15 seconds while a destination is busy. An idle destination checks again every minute.
RetentionCaptured 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

1
Start a destination

Go to Developers → Data Export → Destinations and click Add destination.

2
Choose Webhook

Under Destination type, choose Webhook. Enter a Name and the Webhook URL.

3
Add headers (optional)

If your endpoint needs authentication, click Add header, for example Authorization with your key. Header values are stored encrypted and never shown again.

4
Choose event types and throughput

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).

5
Create it

Click Create and enable. Copy the Signing secret. It starts with whsec_ and is shown only once.

6
Test it

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 2xx response accepts the whole batch. Anything else is retried later from the same bookmark, so make your endpoint idempotent on id.
  • Redirects are not followed. A 3xx counts 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.

JavaScript
// 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
# 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

1
Start a destination

Click Add destination and choose S3-compatible bucket.

2
Enter the bucket

Enter the Bucket, the Region and an Object prefix (optional).

3
Enter credentials

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.

4
Point at another provider (optional)

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.

5
Create and test

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

1
Open Pull feed tokens

Go to Developers → Data Export → Pull feed tokens.

2
Name your integration

Click Create feed token and name it, for example Production warehouse. Use one token per integration so you can revoke them separately.

3
Copy the token

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

Bash
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'
VariableWhat to use
GROVS_API_URLhttps://api.sqd.link on Grovs Cloud. On a self-hosted deployment, your API origin.
GROVS_PROJECT_IDThe 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_TOKENThe complete est_… token.

Without after, the request starts 7 days back.

Query parameterDefaultDescription
afternoneThe next_after value from your last processed page.
limit1001 to 1000. Counts analytics and purchases together.
event_types[]all typesRepeatable, for example event_types[]=install&event_types[]=purchase.

3. Process the page and save the cursor

JSON
{
  "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 events array 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 again

Reads 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

1
Create a replacement

Create a new token for the same project environment.

2
Switch your integration

Update your integration's secret and check it reads from its saved cursor.

3
Revoke the old token

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.

JSON
{
  "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
}
FieldNotes
idThe deduplication key. For purchases it changes with every correction.
typeOne of the event types above, or test.
nameA 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_idInteger IDs. link is null when the event has no link.
user.id, user.attributesThe identifier and attributes your app set through the SDK. Use them to join with your CRM.
geocountry 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_byOn user_referred, the ID of the install that produced it.
purchaseOn 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 newest purchase.version.
  • name is the store action, so a buy and a refund of the same transaction stay separate records.
  • Deduplicate repeated deliveries of the same event by id.

Troubleshooting

StatusMeaningWhat to do
400Malformed cursor, limit or dateRestore the last saved cursor. If it is lost, omit after to re-read the last 7 days and deduplicate by id.
401Missing, expired or revoked credentialCheck the token. Replace a revoked one.
402The instance has no active paid planCheck your subscription or self-hosted entitlement.
403Not an admin, or the token belongs to another projectCheck the project ID in the URL, including the environment.
424The test event reached your endpoint and it refusedFix your endpoint, then test again.
503Rate limited, or delivery is paused platform-wideBack 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.

Last updated 2026-10-26
Was this page helpful?