Events
Notifications
Get told when something happens — a sale, a signup, a deploy. Instantly or in a digest, by email, Slack, Discord, a signed webhook, the macOS app or the iPhone app.
What triggers a notification
Notifications come from events. Two kinds can notify you:
- Product events your code sends with the API or the Node.js SDK. Add
"notify": trueto an event and the built-in rule emails it to you — nothing to set up. - Revenue events Metricneed writes on the
revenuechannel from your payment providers: New sale, Refund, New subscription, Trial started, Cancellation scheduled, Subscription canceled.
{
"channel": "billing",
"event": "Upgrade to Pro",
"user_id": "usr_42",
"value": 24,
"notify": true
}With the SDK:
mn.track({ channel: 'billing', event: 'Upgrade to Pro', userId: 'usr_42', value: 24, notify: true })Milestones you add by hand never notify, and importing your revenue history doesn't either (it only fills chart totals). Each event is checked against your rules once, when it is stored.
Destinations
A destination is where a notification goes. Add them in Settings → Notifications.
| Destination | What you need |
|---|---|
| Nothing — your account email is added automatically. It can be paused, not removed. | |
| Slack | An incoming-webhook URL for a channel (see below). |
| Discord | A channel webhook URL (see below). |
| Webhook | Any HTTPS endpoint you control. Every request is signed. |
| macOS app | Metricneed for Mac, signed in with your account. |
| iPhone app | The Metricneed iPhone app, signed in, with push notifications allowed. Added automatically. |
A destination must pass a test before rules deliver to it: add it, then click Send test. Webhook URLs are secrets — anyone holding a Slack webhook URL can post to your workspace — so they are stored encrypted and only ever shown masked.
Slack
- Go to api.slack.com/apps, click Create New App → From scratch and pick your workspace.
- Open Incoming Webhooks, turn it on, click Add New Webhook to Workspace and choose the channel.
- Copy the URL (
https://hooks.slack.com/services/…), paste it into Add destination → Slack, then send a test.
Discord
- Open the channel's settings (the gear icon) → Integrations → Webhooks.
- Click New Webhook, give it a name, then Copy Webhook URL.
- Paste it into Add destination → Discord, then send a test.
The macOS app
On Pro, Metricneed for Mac shows notifications as native macOS alerts. Add a macOS app destination, then sign in to the app with the same account: the destination is verified the first time the app checks in. The app polls for new notifications, so an alert can arrive a minute or so after the event.
The iPhone app
On Pro, the Metricneed iPhone app receives real push notifications. In the app, open Settings → Push notifications and allow them: the phone registers itself and an iPhone app destination appears here, already verified — it stands for every iPhone signed in to your account. Point rules at it like any other destination; tapping a notification opens the event in the app. A digest arrives as a single notification summing it up. Signing out of the app stops its notifications.
Rules
A rule says which events notify you, where (up to 10 destinations) and how (instantly or in a digest). Conditions:
| Condition | Matches |
|---|---|
| Source | “From code” (SDK and API events) or “From revenue”. |
| Revenue events | New sale, Refund, New subscription, Trial started, Cancellation scheduled, Subscription canceled. |
| Channels | One or more channels, e.g. signups, deploys. |
| Events | One or more event names, e.g. “New signup”. |
| Tags | Up to 5 key = value pairs. All must match; numbers and booleans match their text (42, true). |
| Sent with notify | Only events your code sent with notify: true. |
An event must match every condition the rule sets; within one condition, any of the values matches. A few examples:
- Revenue events: New sale → Slack
#sales, instantly. - Revenue events: Refund, Subscription canceled → email, daily digest.
- Channel
deploys, tagenv = production→ Discord, instantly.
The built-in rule — events sent with notify: true, to your email — comes with your account. You can edit it or turn it off, not delete it. Shortcut: open any event in the feed and click Notify me about these to start a rule for that event.
Instantly or in a digest
- Instantly — sent as soon as the event is stored.
- Daily digest — one message a day at 09:00 in your digest time zone, listing up to 20 events; the rest is summed up as “+N more”.
- Weekly digest — the same, on Mondays at 09:00.
Set the digest time zone in Settings → Notifications; until you do, digests follow UTC. A digest that misses its hour goes out on the next run, so nothing waits much longer than a day (a week for weekly digests).
Anti-spam and failures
- Per rule: At most N per hour caps its instant notifications; Cooldown sets the minutes between two of them. Both are optional.
- Per account: 60 instant notifications per hour, all rules together.
Past any of these caps, events aren't dropped: they go into your next daily digest (“+37 more events”). Nothing is lost.
A delivery that fails is retried with backoff — after 1 minute, then 2, 4, 8… up to an hour between attempts — for 24 hours, then marked failed. After 10 failures in a row the destination is switched off and you get an email. Fix it, then turn it back on: that resets the count. The delivery log in Settings shows the last 7 days — every delivery, its status, attempts and last error.
Plans
| Plan | Delivery | Rules | Destinations |
|---|---|---|---|
| Free | Daily email digest | 3 | |
| Starter | Instant or digest | 5 | Email, Slack, Discord |
| Pro | Instant or digest | Unlimited | Email, Slack, Discord, Webhook, macOS app, iPhone app |
On Free, every rule is delivered in the daily email digest, whatever its mode. After a downgrade, destinations your plan no longer includes are kept but receive nothing until you upgrade again.
Signed webhooks
A Webhook destination receives a POST with a JSON body for every notification:
{
"id": "0b6f2c1e-7d4a-4f53-9d0e-2a51c8e0f7b2",
"type": "event.notification",
"createdAt": "2026-09-12T09:14:03.000Z",
"event": {
"id": "4a3e9c0d-1f2b-4c6d-8e7f-90a1b2c3d4e5",
"source": "api",
"channel": "signups",
"name": "New signup",
"title": null,
"description": "[email protected] joined from the pricing page",
"icon": "user-plus",
"tags": { "plan": "pro" },
"value": null,
"userId": "usr_42",
"occurredAt": "2026-09-12T09:13:58.000Z",
"url": "https://metricneed.com/events?event=4a3e9c0d-1f2b-4c6d-8e7f-90a1b2c3d4e5"
}
}type is event.notification for an instant notification, test when you click Send test, and digest for a digest, which carries events and more (events left out) instead of event:
{
"id": "7c1d…",
"type": "digest",
"createdAt": "2026-09-12T09:00:04.000Z",
"events": [
{ "id": "…", "source": "system", "channel": "revenue", "name": "New sale", "value": 29, … },
{ "id": "…", "source": "api", "channel": "signups", "name": "New signup", … }
],
"more": 37
}Headers:
| Header | Meaning |
|---|---|
Metricneed-Signature | "v1=" followed by the hex HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with the destination's signing secret. |
Metricneed-Timestamp | When the request was signed, in Unix seconds. The same value goes into the signature. |
Metricneed-Delivery | The delivery id (the body's id). A retry reuses it — use it to ignore duplicates. |
To check that a request really comes from Metricneed:
- Read the raw body, before any JSON parsing.
- Reject the request if
Metricneed-Timestampis more than 300 seconds away from now — a replayed request. - Compute the HMAC-SHA256 of
timestamp + "." + rawBodywith your signing secret and compare it with the header in constant time.
// server.mjs — run with: METRICNEED_WEBHOOK_SECRET=… node server.mjs
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.METRICNEED_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300
function verify(rawBody, headers) {
const timestamp = String(headers['metricneed-timestamp'] ?? '')
const signature = String(headers['metricneed-signature'] ?? '')
// Replay protection: refuse a request signed too long ago.
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!timestamp || !Number.isFinite(age) || age > TOLERANCE_SECONDS) return false
if (!signature.startsWith('v1=')) return false
const expected = createHmac('sha256', SECRET).update(timestamp + '.' + rawBody).digest()
const received = Buffer.from(signature.slice(3), 'hex')
// Constant-time comparison (timingSafeEqual throws when the lengths differ).
return received.length === expected.length && timingSafeEqual(received, expected)
}
createServer((req, res) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
// Verify the raw bytes: re-serialized JSON won't match the signature.
const rawBody = Buffer.concat(chunks).toString('utf8')
if (!verify(rawBody, req.headers)) {
res.writeHead(401).end()
return
}
const delivery = JSON.parse(rawBody)
if (delivery.type === 'event.notification') console.log('Event:', delivery.event.name)
if (delivery.type === 'digest') console.log('Digest:', delivery.events.length, 'events +', delivery.more)
res.writeHead(204).end()
})
}).listen(3000) With Express, mount express.raw({ type: 'application/json' }) on that route so the body stays raw. Answer with any 2xx quickly and do the work afterwards: any other status, a timeout or a connection error is retried as described in anti-spam and failures. The signing secret is in the destination's menu (Reveal signing secret); rotating it retires the old one immediately.