Commit ed2e6f5
committed
feat: ParcelWebhookController for carrier tracking event ingestion
Adds POST /webhooks/parcel/{providerKey} endpoint that accepts
carrier webhook payloads from ParcelPath, UPS, and USPS, normalizes
them into TrackingStatus rows, and lets the existing
TrackingStatusObserver (Task 22) handle downstream Order status
transitions + SocketCluster broadcast.
## Architecture: pure normalizer + thin controller
WebhookPayloadNormalizer (pure, unit-tested):
- isValidProvider(string): bool — checks against [parcelpath, ups, usps], case-insensitive
- normalize(providerKey, payload): array — dispatches to provider-specific normalizer:
* ParcelPath: events already carry Fleetbase-compatible codes; uppercased and passed through
* UPS: reuses UPS::upsActivityCodeToFleetbaseCode (I→IN_TRANSIT, D→DELIVERED, etc.)
* USPS: reuses USPS::uspsEventTypeToFleetbaseCode (ALERT→EXCEPTION, rest verbatim)
- dedupKey(trackingNumberUuid, event): array — composite key for TrackingStatus::firstOrCreate
matching the poll jobs' dedup logic exactly
ParcelWebhookController (impure, thin):
1. Validate provider key → 404 if unknown
2. Verify shared secret → 401 if mismatch (see below)
3. Normalize payload via the pure normalizer
4. For each event: resolve TrackingNumber (carrier_tracking_number
index from Task 7, falling back to tracking_number column),
firstOrCreate a TrackingStatus row with the dedup key
5. Return 200 with {processed, skipped} counts
## Authentication: shared-secret header (pluggable)
Checks config('services.{providerKey}.webhook_secret') against the
X-Webhook-Secret request header using hash_equals (timing-safe).
If no secret is configured (null/empty), verification passes — this
makes the endpoint open during development and initial integration
testing. The verification logic is isolated in verifyWebhookSecret()
so it can be replaced with HMAC signing or IP allowlist per provider
without touching the ingestion logic.
## Idempotency
TrackingStatus::firstOrCreate with composite key
(tracking_number_uuid, code, created_at) — identical to the key
used by PollParcelPathTrackingJob, PollUPSTrackingJob, and
PollUSPSTrackingJob. Duplicate webhook deliveries are silently
de-duped. The firstOrCreate match returns the existing row without
inserting; the TrackingStatusObserver only fires on actual `created`
events, so duplicate deliveries also don't trigger redundant Order
status transitions.
## Per-event error isolation
A malformed event (missing tracking number, unresolvable
TrackingNumber record, Eloquent exception) is skipped and report()ed
but does not abort the webhook request. The response always returns
200 so the webhook sender considers delivery successful and doesn't
enter an infinite retry loop on permanently malformed events.
## Expected webhook request shapes
ParcelPath:
POST /webhooks/parcel/parcelpath
X-Webhook-Secret: {configured secret}
{
"tracking_number": "1Z999AA10123456784",
"carrier": "UPS",
"events": [
{"code": "IN_TRANSIT", "status": "In Transit", "timestamp": "2026-04-07T10:00:00Z", "location": "Chicago"}
]
}
UPS (single event):
POST /webhooks/parcel/ups
X-Webhook-Secret: {configured secret}
{
"trackingNumber": "1Z999AA10123456784",
"eventType": "D",
"eventDescription": "Delivered",
"eventTimestamp": "2026-04-09T14:22:00",
"eventCity": "New York",
"eventState": "NY"
}
USPS (single event):
POST /webhooks/parcel/usps
X-Webhook-Secret: {configured secret}
{
"trackingNumber": "9400111202555999999999",
"eventType": "DELIVERED",
"eventTimestamp": "2026-04-09T14:22:00",
"eventCity": "New York"
}
## Route registration
POST /webhooks/parcel/{providerKey} added to the existing webhooks
group in routes.php, alongside the telematics webhook routes.
Follows the same pattern (providerKey path parameter, any-method
for telematics → POST-only for parcel).
Tests (Pest): 16 new normalizer tests covering ParcelPath / UPS /
USPS normalization, code mapping reuse, dedupKey composition,
provider validation, and edge cases (missing tracking number, no
events, unknown provider). Full suite: 301 passed (644 assertions).1 parent f0f0f6c commit ed2e6f5
4 files changed
Lines changed: 570 additions & 0 deletions
File tree
- server
- src
- Http/Controllers/Api/v1
- Support
- tests/Support
Lines changed: 158 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
0 commit comments