Sending from the API

Grit job worker → Expo Push.

8 minmedium

Phone is registered, token is in the DB. Now the API sends a push. We use Expo's push service — one HTTP endpoint, no APNs / FCM wiring on our side, no certificates. Job worker dispatches; user sees a banner.

The Expo Push endpoint

POST https://exp.host/--/api/v2/push/send
{
"to": "ExponentPushToken[xxx]",
"title": "Order shipped",
"body": "Your headphones are on the way",
"data": { "orderId": "abc123" },
"sound": "default"
}

Pass the user's saved push token in to, the visible text in title + body, and any deep-link payload in data.

From a Grit job worker

apps/api/internal/jobs/push.go
const TaskTypeSendPush = "push:send"
type SendPushPayload struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
Data map[string]any `json:"data,omitempty"`
}
func (j *Jobs) EnqueueSendPush(ctx context.Context, userID, title, body string, data map[string]any) error {
payload, _ := json.Marshal(SendPushPayload{UserID: userID, Title: title, Body: body, Data: data})
_, err := j.client.EnqueueContext(ctx, asynq.NewTask(TaskTypeSendPush, payload),
asynq.MaxRetry(5),
)
return err
}
func (j *Jobs) HandleSendPush(ctx context.Context, task *asynq.Task) error {
var p SendPushPayload
json.Unmarshal(task.Payload(), &p)
user, err := j.users.GetByID(ctx, p.UserID)
if err != nil { return err }
if user.PushToken == "" {
return nil // not registered — nothing to do
}
body, _ := json.Marshal(map[string]any{
"to": user.PushToken,
"title": p.Title,
"body": p.Body,
"data": p.Data,
"sound": "default",
})
resp, err := http.Post("https://exp.host/--/api/v2/push/send",
"application/json", bytes.NewReader(body))
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("expo push: HTTP %d", resp.StatusCode)
}
return nil
}

Triggering it from anywhere

Any handler can fire a push by enqueuing the job. Example — notifying a user when an order ships:

apps/api/internal/handlers/order.go (excerpt)
func (h *OrderHandler) MarkShipped(c *gin.Context) {
order, _ := h.svc.MarkShipped(ctx, id)
h.jobs.EnqueueSendPush(ctx, order.UserID,
"Order shipped",
"Your order is on the way",
map[string]any{"orderId": order.ID.String()},
)
respond.OK(c, order, "Order shipped")
}

The handler returns immediately. The worker picks up the job and sends the push asynchronously. If Expo's push API is briefly down, Asynq retries automatically.

Error codes from Expo

Expo's response includes per-token results:

{
"data": [
{ "status": "ok", "id": "receipt-id-1" },
{ "status": "error", "message": "...",
"details": { "error": "DeviceNotRegistered" } }
]
}

Worth handling: DeviceNotRegistered means the token is dead (app uninstalled, push revoked). Mark the user's push_token as empty so you stop trying.

Batch by default. Expo accepts up to 100 messages in a single POST. If you're fanning out to thousands, batch them. For the "welcome push" / "order shipped" case, single sends are fine.

Foreground display

Remember from the last lesson — by default Expo doesn't show notifications when your app is in the foreground. Set the notification handler to shouldShowAlert: true if you want banners regardless.

Quick check

A push job fails because Expo's API returns 'DeviceNotRegistered'. What should the worker do?

Try it

Send yourself a push from your API:

  1. Add the HandleSendPush job + register it with your worker mux.
  2. Add a debug endpoint POST /api/notify/me that enqueues a push to the authenticated user.
  3. Make sure your push_token is saved on your user row (from last lesson).
  4. Curl the endpoint with your auth token.
  5. Push should arrive on your phone within ~5 seconds.

That's chapter 4's assignment done. Paste a screenshot of the notification on your lock screen in notes.md.

What's next

Final chapter — Ship It. EAS Build for store-ready binaries, App Store + Play Store submission, OTA updates.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub