SendOps does not send push notifications. It sends email. What it can do is **signal your system** at the right moment in a contact's journey, wait for the answer, and carry on differently depending on what you said — which is enough to put a push and an email drip in one campaign instead of two disconnected ones.

The division of labour is worth being blunt about:

- **SendOps** decides *who* gets a nudge and *when*, using the same triggers, waits, segments and consent rules as the rest of the workflow.
- **Your system** owns the device tokens and actually delivers the push through APNs, FCM, or whichever provider you use.
- **SendOps never talks to a push provider**, holds a device token, or knows whether a phone is registered. It asks; you answer.

## The flow

```sendflow
workflow "Cart abandonment" v1 {
  enter on activity.cart_abandoned
  exit "purchased" when exists(activity.order within 1d)

  wait 1h
  call webhook "mobile-push" as "cart-reminder" {
    failed: send "cart-reminder" via topic "marketing"
  }
  wait up to 6h until exists(activity.push_opened) {
    timeout: send "cart-reminder" via topic "marketing"
  }
}
```

Reading it back:

1. A contact abandons a cart — your app records that as an [activity](/audience/activities) — and the workflow enrolls them. If they buy within a day, the named exit takes them out.
2. An hour later, `call webhook "mobile-push"` POSTs a signed envelope about that contact to your service and parks the run.
3. Your service answers. **`2xx`** and the run falls through to the wait. Anything else and the `failed:` arm sends the same reminder as an email — which is exactly what you want when the answer was "I have no device for this person".
4. The run then waits up to six hours for a `push_opened` activity. If it never arrives, the `timeout:` arm emails them anyway.


  A `call webhook` step's `failed:` arm exists so a flow can degrade instead of stalling. Nothing about a push going wrong — no device, provider outage, your service down — can kill the run. The worst case is the email it would have sent anyway. See [the outcome table](/workflows/flow-reference#calling-your-own-systems) for exactly which answers land on which path.


Before this runs you need the endpoint itself: **Connections → Webhooks → Add Endpoint**, **Use for: Workflows**, key `mobile-push`. [Calling your own systems](/workflows/authoring#calling-your-own-systems) covers that, the canvas node, and what the run timeline shows.

## The receiver

Your endpoint receives one `workflow.step` envelope per contact. It has three jobs: prove the request is ours, refuse to act twice on the same call, and answer honestly.

```js
const express = require('express')
const app = express()

app.post('/hooks/push', express.raw({ type: 'application/json' }), async (req, res) => {
  // 1. Verify the signature over the RAW bytes, before parsing.
  if (!verify(req, process.env.SENDOPS_WEBHOOK_SECRET)) return res.sendStatus(401)

  const evt = JSON.parse(req.body)
  if (evt.type !== 'workflow.step') return res.sendStatus(204)

  // 2. Deduplicate on the delivery id. Retries carry the same one.
  if (await seen(evt.id)) return res.sendStatus(204)

  // 3. Find the device by YOUR id for this person.
  const device = await devices.findByUserId(evt.contact.external_id)
  if (!device) {
    // Honest "no". Not retried; the run takes its failed: arm and emails them.
    return res.sendStatus(404)
  }

  // 4. Answer first, deliver after. The run is parked until you reply.
  await markSeen(evt.id)
  res.sendStatus(204)
  queue.push({ device, label: evt.label, contactId: evt.contact.id })
})
```

Four things in that sketch are load-bearing:

- **Verify before parsing.** The signature is computed over the raw request body, so re-serializing the JSON breaks it. The scheme is identical to [notification webhooks](/notifications/webhooks#verifying-webhook-signatures) — same secret format, same headers, same constant-time comparison.
- **Deduplicate on `evt.id`.** It is stable across every retry and also arrives as the `X-SendOps-Delivery-Id` header, so you can check it before reading the body. Delivery is at-least-once.
- **`404` is a feature.** A `4xx` other than `408` or `429` means you answered, and SendOps believes you: no retries, no mark against your endpoint's health, and the run takes its `failed:` arm. "No device for this contact" is the case it was designed for. A `5xx` says the opposite — try me again — and gets retried at 0, 5 and 15 minutes.
- **Answer inside 10 seconds.** The response budget is ten seconds and the run is parked meanwhile. Acknowledge, then do the provider call on a queue.

The contact's own data rides along: `evt.contact.email`, `evt.contact.external_id` (your id for them, if you have ever supplied one), and `evt.contact.attributes` — every attribute they have, typed as stored. `evt.label` is the `as "cart-reminder"` name, so one endpoint can serve several steps of a flow and tell them apart.


  Every field, the exact signing string, verification code in Go, Node and Python, the retry and deadline rules, and the per-endpoint rate limit are on the [developer documentation](https://developers.sendops.dev/api-reference/workflow-webhook). Write your receiver against that page.


## Closing the loop

The flow above waits on `activity.push_opened`, and nothing produces that activity unless you do. When someone taps the push, record it:

```
POST /v1/activities
{
  "name": "push_opened",
  "external_id": "cust_84213",
  "occurred_at": "2026-09-19T11:02:11Z",
  "idempotency_key": "push-open-8a2b4c6d"
}
```

That single call is what lets a workflow branch on something that happened outside SendOps entirely. Once the activity exists, the rest of the language can use it: `wait up to 6h until exists(activity.push_opened)` here, `if not exists(activity.push_opened within 7d)` for a later nudge, or a [Segment](/audience/segments) of people who never open pushes and should be emailed first next time.


  Recording an activity for a person SendOps has never seen creates a stub contact, which counts in rules but cannot receive email. If your push audience and your email audience are meant to be the same people, keep the [contact upsert](/audience/syncing-external-state) running too.


## What's next?

- [Calling your own systems](/workflows/authoring#calling-your-own-systems) — creating the endpoint, the canvas node, and reading a run's timeline.
- [SendFlow reference](/workflows/flow-reference#calling-your-own-systems) — the statement, the label rule, and every outcome.
- [Webhooks](/notifications/webhooks#workflow-endpoints) — running endpoints: keys, Used by, the Failing state, and why a referenced endpoint cannot be deleted.
- [Activities](/audience/activities) — recording behaviour SendOps cannot see for itself.