Sending from the API
Grit job worker → Expo Push.
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
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 SendPushPayloadjson.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:
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.
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
Try it
Send yourself a push from your API:
- Add the
HandleSendPushjob + register it with your worker mux. - Add a debug endpoint
POST /api/notify/methat enqueues a push to the authenticated user. - Make sure your push_token is saved on your user row (from last lesson).
- Curl the endpoint with your auth token.
- 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