{"info":{"_postman_id":"trackcourier-api-collection","name":"TrackcourierAPI","description":"# TrackcourierAPI\n\nUnified courier tracking API for DTDC, Blue Dart, Delhivery, Speed Post and many more couriers.\n\n## Getting Started\n\n1. **Get your API key** at [api.trackcourier.io](https://api.trackcourier.io)\n2. **Set environment variable**: Add your API key to the `api_key` variable\n3. **Start tracking**: Use the \"Track Shipment\" request\n4. **Receive updates by webhook**: set `webhook_url` and run the \"Webhooks\" folder in order\n\nWhen you download the collection, each tracking request gets a real, recently delivered tracking number where we have one, so you can press Send straight away. If you see `YOUR_TRACKING_NUMBER`, replace it with your own.\n\n## Authentication\n\nEvery request except Get Plans and Check a signature sends your API key in the `X-API-Key` header.\n\n## Rate Limits\n\n| Plan | Requests/month | Rate limit |\n|------|----------------|------------|\n| Free | 100 | 2/sec |\n| Starter (₹499/mo) | 500 | 5/sec |\n| Growth (₹1,999/mo) | 3,000 | 10/sec |\n| Business (₹4,999/mo) | 10,000 | 20/sec |\n\n## Support\n\n- Documentation: [api.trackcourier.io/docs](https://api.trackcourier.io/docs)\n- Email: api@trackcourier.io","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json"},"auth":{"type":"apikey","apikey":[{"key":"key","value":"X-API-Key","type":"string"},{"key":"value","value":"{{api_key}}","type":"string"},{"key":"in","value":"header","type":"string"}]},"item":[{"name":"Tracking","description":"Track shipments from Indian and international couriers.","item":[{"name":"Track Shipment","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=dtdc&tracking_number={{dtdc_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"dtdc","description":"Courier slug (e.g., dtdc, bluedart, delhivery)"},{"key":"tracking_number","value":"{{dtdc_tracking}}","description":"Tracking/AWB number"}]},"description":"Track a shipment by courier and tracking number.\n\n**Response includes:**\n- `ShipmentState`: Current status (delivered, intransit, outfordelivery, etc.)\n- `MostRecentStatus`: Human-readable status\n- `Checkpoints`: Array of tracking events with date, time, location, and activity\n- `usage`: Your current quota usage\n\nThe tracking number is a real, recently delivered shipment when we have one, filled in when you download the collection. Replace it with your own to track anything else."},"response":[{"name":"Successful Track","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=dtdc&tracking_number={{dtdc_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"dtdc"},{"key":"tracking_number","value":"{{dtdc_tracking}}"}]}},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"success\": true,\n  \"data\": {\n    \"ShipmentState\": \"delivered\",\n    \"MostRecentStatus\": \"Delivered\",\n    \"Checkpoints\": [\n      {\n        \"Date\": \"30-Jan-2026\",\n        \"Time\": \"09:04\",\n        \"Location\": \"Bommanahalli Branch\",\n        \"Activity\": \"Delivered\",\n        \"CheckpointState\": \"delivered\"\n      },\n      {\n        \"Date\": \"30-Jan-2026\",\n        \"Time\": \"07:01\",\n        \"Location\": \"Bommanahalli Branch\",\n        \"Activity\": \"Out For Delivery\",\n        \"CheckpointState\": \"outfordelivery\"\n      },\n      {\n        \"Date\": \"20-Jan-2026\",\n        \"Time\": \"15:16\",\n        \"Location\": \"Rohini Branch\",\n        \"Activity\": \"Booked at Origin Branch\",\n        \"CheckpointState\": \"inforeceived\"\n      }\n    ],\n    \"Result\": \"success\"\n  },\n  \"usage\": {\n    \"used\": 5,\n    \"quota\": 100,\n    \"plan\": \"free\",\n    \"percent\": 5.0\n  }\n}"},{"name":"Quota Exceeded","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=dtdc&tracking_number={{dtdc_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"dtdc"},{"key":"tracking_number","value":"{{dtdc_tracking}}"}]}},"status":"Payment Required","code":402,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"success\": false,\n  \"error\": {\n    \"code\": \"QUOTA_EXCEEDED\",\n    \"message\": \"Monthly quota of 100 requests exceeded. Upgrade your plan for more.\\n\\nThis same key works again as soon as you upgrade: https://api.trackcourier.io/pricing\"\n  },\n  \"usage\": {\n    \"used\": 100,\n    \"quota\": 100,\n    \"plan\": \"free\",\n    \"percent\": 100.0\n  }\n}"}]},{"name":"Track - Blue Dart","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=bluedart&tracking_number={{bluedart_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"bluedart"},{"key":"tracking_number","value":"{{bluedart_tracking}}"}]},"description":"Track a Blue Dart shipment.\n\nBlue Dart tracking numbers are typically 10-11 digits."},"response":[]},{"name":"Track - Delhivery","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=delhivery&tracking_number={{delhivery_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"delhivery"},{"key":"tracking_number","value":"{{delhivery_tracking}}"}]},"description":"Track a Delhivery shipment.\n\nDelhivery tracking numbers are typically 13-14 digits."},"response":[]},{"name":"Track - Speed Post","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=speedpost&tracking_number={{speedpost_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"speedpost"},{"key":"tracking_number","value":"{{speedpost_tracking}}"}]},"description":"Track a Speed Post (India Post EMS) shipment.\n\nSpeed Post tracking numbers follow the format: `EE123456789IN`\n\n**Note:** A Speed Post lookup can take up to 30 seconds."},"response":[]},{"name":"Track - India Post","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/track?courier=indiapost&tracking_number={{indiapost_tracking}}","host":["{{base_url}}"],"path":["v1","track"],"query":[{"key":"courier","value":"indiapost"},{"key":"tracking_number","value":"{{indiapost_tracking}}"}]},"description":"Track an India Post shipment.\n\nIndia Post tracking numbers follow the format: `RK123456789IN`\n\n**Note:** An India Post lookup can take up to 30 seconds."},"response":[]}]},{"name":"Couriers","description":"Get information about supported courier services.","item":[{"name":"List All Couriers","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/couriers","host":["{{base_url}}"],"path":["v1","couriers"]},"description":"Get every courier you can track, with the slug to use in your requests. This request does not count against your quota.\n\nReturns courier `slug` (for API calls) and `name` (display name)."},"response":[{"name":"Couriers List","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/couriers","host":["{{base_url}}"],"path":["v1","couriers"]}},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"success\": true,\n  \"data\": {\n    \"couriers\": [\n      {\n        \"slug\": \"bluedart\",\n        \"name\": \"Blue Dart\"\n      },\n      {\n        \"slug\": \"delhivery\",\n        \"name\": \"Delhivery\"\n      },\n      {\n        \"slug\": \"dtdc\",\n        \"name\": \"DTDC\"\n      },\n      {\n        \"slug\": \"indiapost\",\n        \"name\": \"India Post\"\n      },\n      {\n        \"slug\": \"speedpost\",\n        \"name\": \"Speed Post\"\n      },\n      {\n        \"slug\": \"xpressbees\",\n        \"name\": \"XpressBees\"\n      },\n      {\n        \"slug\": \"shadowfax\",\n        \"name\": \"Shadowfax\"\n      },\n      {\n        \"slug\": \"ekart\",\n        \"name\": \"Ekart Logistics\"\n      },\n      {\n        \"slug\": \"trackon\",\n        \"name\": \"Trackon Courier\"\n      },\n      {\n        \"slug\": \"shreemaruti\",\n        \"name\": \"Shree Maruti\"\n      },\n      {\n        \"slug\": \"professional\",\n        \"name\": \"Professional Courier\"\n      },\n      {\n        \"slug\": \"gati\",\n        \"name\": \"Gati\"\n      },\n      {\n        \"slug\": \"safexpress\",\n        \"name\": \"Safexpress\"\n      },\n      {\n        \"slug\": \"canadapost\",\n        \"name\": \"Canada Post\"\n      },\n      {\n        \"slug\": \"shreetirupati\",\n        \"name\": \"Shree Tirupati Courier\"\n      },\n      {\n        \"slug\": \"shreeanjani\",\n        \"name\": \"Shree Anjani Courier\"\n      },\n      {\n        \"slug\": \"stcourier\",\n        \"name\": \"ST Courier\"\n      },\n      {\n        \"slug\": \"vrl\",\n        \"name\": \"VRL Logistics\"\n      },\n      {\n        \"slug\": \"skyking\",\n        \"name\": \"Skyking\"\n      },\n      {\n        \"slug\": \"shreemahavir\",\n        \"name\": \"Shree Mahavir Courier\"\n      },\n      {\n        \"slug\": \"shreenandan\",\n        \"name\": \"Shree Nandan Courier\"\n      },\n      {\n        \"slug\": \"shiprocket\",\n        \"name\": \"Shiprocket\"\n      },\n      {\n        \"slug\": \"indiapostinternational\",\n        \"name\": \"India Post International\"\n      },\n      {\n        \"slug\": \"tciexpress\",\n        \"name\": \"TCI Express\"\n      },\n      {\n        \"slug\": \"omlogistics\",\n        \"name\": \"Om Logistics\"\n      },\n      {\n        \"slug\": \"vtrans\",\n        \"name\": \"V-Trans\"\n      },\n      {\n        \"slug\": \"rivigo\",\n        \"name\": \"Rivigo\"\n      },\n      {\n        \"slug\": \"tcifreight\",\n        \"name\": \"TCI Freight\"\n      },\n      {\n        \"slug\": \"arccarrier\",\n        \"name\": \"ARC Carrier\"\n      },\n      {\n        \"slug\": \"madhur\",\n        \"name\": \"Madhur Courier\"\n      },\n      {\n        \"slug\": \"registeredpost\",\n        \"name\": \"Registered Post\"\n      },\n      {\n        \"slug\": \"vxpress\",\n        \"name\": \"V-Xpress\"\n      },\n      {\n        \"slug\": \"nitco\",\n        \"name\": \"NITCO Roadways\"\n      },\n      {\n        \"slug\": \"poslaju\",\n        \"name\": \"Pos Laju\"\n      },\n      {\n        \"slug\": \"gmsworldwide\",\n        \"name\": \"GMS Worldwide\"\n      },\n      {\n        \"slug\": \"markexpress\",\n        \"name\": \"Mark Express\"\n      },\n      {\n        \"slug\": \"dpworld\",\n        \"name\": \"DP World Express\"\n      }\n    ],\n    \"count\": 37\n  }\n}"}]},{"name":"Get Courier Details","request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/couriers/dtdc","host":["{{base_url}}"],"path":["v1","couriers","dtdc"]},"description":"Get details about a specific courier.\n\nReplace `dtdc` in the URL with any courier slug."},"response":[]}]},{"name":"Plans & Pricing","description":"View available subscription plans. No authentication required.","item":[{"name":"Get Plans","request":{"auth":{"type":"noauth"},"method":"GET","header":[],"url":{"raw":"{{base_url}}/billing/plans","host":["{{base_url}}"],"path":["billing","plans"]},"description":"Get available subscription plans with pricing.\n\n**No authentication required.**\n\nPricing is in INR (Indian Rupees). Annual plans offer 2 months free."},"response":[{"name":"Plans List","originalRequest":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/billing/plans","host":["{{base_url}}"],"path":["billing","plans"]}},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"body":"{\n  \"plans\": [\n    {\n      \"id\": \"starter\",\n      \"name\": \"Starter\",\n      \"monthly_price\": 499,\n      \"yearly_price\": 4990,\n      \"yearly_available\": true,\n      \"quota\": 500,\n      \"rate_limit\": 5\n    },\n    {\n      \"id\": \"growth\",\n      \"name\": \"Growth\",\n      \"monthly_price\": 1999,\n      \"yearly_price\": 19990,\n      \"yearly_available\": true,\n      \"quota\": 3000,\n      \"rate_limit\": 10\n    },\n    {\n      \"id\": \"business\",\n      \"name\": \"Business\",\n      \"monthly_price\": 4999,\n      \"yearly_price\": 49990,\n      \"yearly_available\": true,\n      \"quota\": 10000,\n      \"rate_limit\": 20\n    }\n  ]\n}"}]}]},{"name":"Webhooks","description":"Instead of asking us for a shipment's status, receive every change as a signed `POST` to your own URL.\n\n## Run it in order\n\n1. Set `api_key`, and set `webhook_url` to your receiver: a public `https://` URL that answers `2xx`. It must be reachable from the internet; a quick way in development is `cloudflared tunnel --url http://localhost:8080`.\n2. Run this folder in order, or send each request yourself. Request 1 stores the webhook's id and your signing secret for the requests after it. With `webhook_url` unset, the folder is skipped, not failed, so running the whole collection with only `api_key` stays green.\n\n| # | request | proves |\n|---|---|---|\n| 1 | Create a test webhook | you can register a URL, and you receive your signing secret |\n| 2-3 | List, Get | the webhook exists and is active |\n| 4 | Fire a test event | a signed delivery reaches your receiver and it answers 2xx |\n| 5 | Check a signature | your signature code agrees with ours |\n| 6 | Update | you can change a webhook |\n| 7 | Rotate your signing secret | you can replace the secret (read its description first) |\n| 8 | Delete | you can remove a webhook |\n\n## Every delivery\n\n`POST` with `Content-Type: application/json` and these headers:\n\n| header | value |\n|---|---|\n| `X-Trackcourier-Signature` | `sha256=` + hex HMAC-SHA256 of the raw body, keyed with your signing secret |\n| `X-Webhook-Event` | e.g. `tracking.delivered` |\n| `X-Webhook-Delivery` | `<webhook_id>-<attempt>`, which changes on every retry, so do not de-duplicate on it |\n| `X-Webhook-Mode` | `test` or `live` |\n\nAnswer `2xx` at the URL you registered; anything else is retried. Do not answer with a redirect.\n\n## Limits\n\n- Up to **5 test webhooks** per account at a time. Delete the ones you are done with.\n- Up to **100 test fires per hour**.\n- Test webhooks, test fires, List, Get and Delete work on every plan. Update and Rotate need an account with live webhooks enabled; a `404 Not Found` from either means yours is not, so write to api@trackcourier.io.","item":[{"name":"1. Create a test webhook","event":[{"listen":"prerequest","script":{"type":"text/javascript","exec":["// Unset webhook_url means you are not testing webhooks now: skip QUIETLY, so running","// the whole collection with only api_key stays green. The marker line lets a scheduled","// run assert that the Webhooks folder really ran. A URL that is set but not https:// is","// a mistake: fail visibly. An invented default would create a webhook pointing nowhere.","// The log or failing test comes BEFORE the skip: skipRequest() ends this script.","const receiverUrl = (pm.variables.get(\"webhook_url\") || \"\").trim();","if (!receiverUrl) {","    console.log(\"TRACKCOURIER_WEBHOOKS_FOLDER_SKIPPED_WEBHOOK_URL_NOT_SET: set webhook_url to your receiver's public https:// URL to run the Webhooks folder.\");","    pm.execution.skipRequest();","} else if (!receiverUrl.startsWith(\"https://\")) {","    const reason = \"webhook_url must be your receiver's public https:// URL. Test deliveries are POSTed there.\";","    console.log(reason);","    pm.test(\"Not sent: webhook_url is not an https:// URL\", function () { throw new Error(reason); });","    pm.execution.skipRequest();","}"]}},{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Webhook created (201)\", function () {","    pm.response.to.have.status(201);","});","","const webhook = pm.response.json();","pm.collectionVariables.set(\"webhook_id\", webhook.id);","","pm.test(\"It is a test-mode webhook\", function () {","    pm.expect(webhook.id).to.match(/^test_/);","});","","// The signing secret is shown ONCE: on the create that makes your account's","// first webhook. After that this field carries a notice starting with \"<\",","// and the secret you saved the first time still signs every delivery.","if (typeof webhook.secret === \"string\" && webhook.secret && !webhook.secret.startsWith(\"<\")) {","    pm.collectionVariables.set(\"webhook_secret\", webhook.secret);","    console.log(\"Signing secret stored in webhook_secret. Save it somewhere safe now: it is not shown again.\");","} else {","    console.log(\"Your account already has a signing secret, so it is not shown again. Use the one you saved. If it is lost, see request 7.\");","}"]}}],"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"{{webhook_url}}\",\n  \"mode\": \"test\",\n  \"events\": [\n    \"tracking.updated\",\n    \"tracking.in_transit\",\n    \"tracking.out_for_delivery\",\n    \"tracking.delivered\",\n    \"tracking.exception\"\n  ],\n  \"description\": \"Created from the Postman collection\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{base_url}}/v1/webhooks","host":["{{base_url}}"],"path":["v1","webhooks"]},"description":"Creates a **test-mode** webhook that POSTs to `{{webhook_url}}`.\n\nA test-mode webhook only ever receives the test events you fire at it (request 4), never a real parcel, so it is safe to point at a receiver you are still building.\n\n**The signing secret is in this response, and only here.** It comes back on the create that makes your account's first webhook, and is stored for you in `webhook_secret`. Every later create returns a notice in its place: one secret signs every webhook on your account.\n\n`events` can be any of `tracking.updated`, `tracking.in_transit`, `tracking.out_for_delivery`, `tracking.delivered` and `tracking.exception`."},"response":[]},{"name":"2. List your webhooks","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Status code is 200\", function () {","    pm.response.to.have.status(200);","});","","const webhooks = pm.response.json();","// Only when this run is testing webhooks and request 1 stored an id: List also runs","// when webhook_url is unset, and an earlier run may have left an id behind.","if (pm.variables.get(\"webhook_url\") && pm.variables.get(\"webhook_id\")) {","    pm.test(\"The webhook created in request 1 is listed\", function () {","        pm.expect(webhooks.map(function (w) { return w.id; })).to.include(pm.variables.get(\"webhook_id\"));","    });","}","pm.test(\"A read never returns the signing secret\", function () {","    webhooks.forEach(function (w) { pm.expect(w).to.not.have.property(\"secret\"); });","});"]}}],"request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/webhooks","host":["{{base_url}}"],"path":["v1","webhooks"]},"description":"Lists every webhook on your account, test and live. The signing secret is never included."},"response":[]},{"name":"3. Get this webhook","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Status code is 200\", function () {","    pm.response.to.have.status(200);","});","","const webhook = pm.response.json();","pm.test(\"It is the webhook created in request 1, and it is active\", function () {","    pm.expect(webhook.id).to.eql(pm.collectionVariables.get(\"webhook_id\"));","    pm.expect(webhook.active).to.eql(true);","});","pm.test(\"A read never returns the signing secret\", function () {","    pm.expect(webhook).to.not.have.property(\"secret\");","});"]}}],"request":{"method":"GET","header":[],"url":{"raw":"{{base_url}}/v1/webhooks/{{webhook_id}}","host":["{{base_url}}"],"path":["v1","webhooks","{{webhook_id}}"]},"description":"Reads one webhook. `last_triggered_at` moves when a delivery chain ENDS: with your 2xx (then `failure_count` is 0), or after the last retry fails. Read the two together."},"response":[]},{"name":"4. Fire a test event at it","event":[{"listen":"prerequest","script":{"type":"text/javascript","exec":["// Remember when this webhook last delivered, so the Tests below can tell a NEW delivery","// from an old one without trusting two clocks to agree. A failed read is recorded as","// such: treating it as \"never delivered\" would let an OLD timestamp pass as new.","pm.sendRequest({","    url: pm.variables.replaceIn(\"{{base_url}}/v1/webhooks/{{webhook_id}}\"),","    method: \"GET\",","    header: { \"X-API-Key\": pm.variables.get(\"api_key\") }","}, function (err, res) {","    let before = \"unreadable\";","    if (!err && res.code === 200) {","        before = res.json().last_triggered_at || \"never\";","    }","    pm.collectionVariables.set(\"webhook_last_delivery_before_test_fire\", before);","});"]}},{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Test event accepted (200), one delivery queued\", function () {","    pm.response.to.have.status(200);","    pm.expect(pm.response.json().enqueued).to.eql(1);","});","","// Wait for the delivery to reach your receiver. last_triggered_at moves when a delivery","// chain ENDS; failure_count 0 means it ended with your receiver's 2xx.","const before = pm.collectionVariables.get(\"webhook_last_delivery_before_test_fire\") || \"unreadable\";","const webhookUrl = pm.variables.replaceIn(\"{{base_url}}/v1/webhooks/{{webhook_id}}\");","const apiKeyHeader = { \"X-API-Key\": pm.variables.get(\"api_key\") };","const failedMessage = \"No successful delivery after 20 seconds. Check that \" + pm.variables.get(\"webhook_url\") +","    \" is reachable from the internet over https and answers 2xx. A failed delivery is retried after 1 minute, \" +","    \"but only while the webhook exists: running the whole folder deletes it at request 8.\";","","if (before === \"unreadable\") {","    pm.test(\"Your receiver got the test delivery and answered 2xx\", function () {","        throw new Error(\"Could not read the webhook before firing, so a new delivery cannot be told from an old one. Send this request again.\");","    });","} else {","    const checkDelivered = function (checksLeft) {","        pm.sendRequest({ url: webhookUrl, method: \"GET\", header: apiKeyHeader }, function (err, res) {","            const webhook = (!err && res.code === 200) ? res.json() : {};","            const delivered = Boolean(webhook.last_triggered_at) &&","                webhook.last_triggered_at !== before && webhook.failure_count === 0;","            if (delivered || checksLeft === 0) {","                pm.test(\"Your receiver got the test delivery and answered 2xx\", function () {","                    pm.expect(delivered, failedMessage).to.eql(true);","                });","                return;","            }","            setTimeout(function () { checkDelivered(checksLeft - 1); }, 2000);","        });","    };","    checkDelivered(10);","}"]}}],"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"event\": \"tracking.delivered\",\n  \"courier\": \"dtdc\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{base_url}}/v1/webhooks/{{webhook_id}}/test","host":["{{base_url}}"],"path":["v1","webhooks","{{webhook_id}}","test"]},"description":"Sends one signed test delivery to your receiver, then waits up to 20 seconds for your receiver to answer 2xx.\n\nThe delivery carries `X-Webhook-Mode: test`, a made-up tracking number and the courier `synthetic-test-dtdc`, so it can never be mistaken for a real parcel.\n\nUse it to prove your **signature check, your transport and your 2xx**. Do not use it to test your checkpoint parser: a test delivery's checkpoints carry `status`, `location` and an ISO-8601 `date`, while a live delivery's carry `activity`, `checkpoint_state`, `courier_name`, `date` (`DD-MMM-YYYY`), `time` and `location`.\n\n`event` can be any event the webhook subscribes to."},"response":[]},{"name":"5. Check a signature","event":[{"listen":"test","script":{"type":"text/javascript","exec":["// Nothing about your webhook is sent: /health is here only because Postman","// runs scripts around a request. The check itself happens below, in Postman.","// Named cryptoJs, not CryptoJS: the sandbox already defines a global of that name.","const cryptoJs = require(\"crypto-js\");","","function sign(secret, rawBody) {","    // HMAC-SHA256 over the RAW body, keyed with the secret AS TEXT (not hex-decoded).","    return \"sha256=\" + cryptoJs.HmacSHA256(rawBody, secret).toString(cryptoJs.enc.Hex);","}","","pm.test(\"HMAC-SHA256 gives the published example signature\", function () {","    const example = sign(","        pm.collectionVariables.get(\"webhook_signature_example_secret\"),","        pm.collectionVariables.get(\"webhook_signature_example_body\"));","    pm.expect(example).to.eql(pm.collectionVariables.get(\"webhook_signature_example_expected\"));","});","","const receivedBody = pm.variables.get(\"webhook_received_body\") || \"\";","const receivedSignature = (pm.variables.get(\"webhook_received_signature\") || \"\").trim();","if (receivedBody && receivedSignature) {","    const expected = sign(pm.variables.get(\"webhook_secret\") || \"\", receivedBody);","    console.log(\"Expected X-Trackcourier-Signature: \" + expected);","    pm.test(\"The delivery you pasted verifies with webhook_secret\", function () {","        pm.expect(receivedSignature,","            \"Expected \" + expected + \". If they differ: the body must be the exact bytes received, \" +","            \"not re-serialised JSON, and webhook_secret must be your current secret.\"","        ).to.eql(expected);","    });","} else {","    console.log(\"To check a delivery your receiver got: paste its raw body into webhook_received_body \" +","        \"and its X-Trackcourier-Signature header into webhook_received_signature, then send again.\");","}"]}}],"request":{"auth":{"type":"noauth"},"method":"GET","header":[],"url":{"raw":"{{base_url}}/health","host":["{{base_url}}"],"path":["health"]},"description":"Computes signatures **inside Postman**, so you can compare your own code against ours.\n\n```\nX-Trackcourier-Signature = \"sha256=\" + hex( HMAC_SHA256(key = your signing secret, message = the raw request body) )\n```\n\n1. **Without a receiver:** your code must turn `webhook_signature_example_secret` and `webhook_signature_example_body` into `webhook_signature_example_expected`. The example secret is 64 hex characters like a real one: use it as text, do not hex-decode it.\n2. **With a delivery in hand:** paste its raw body into `webhook_received_body` and its `X-Trackcourier-Signature` header into `webhook_received_signature`, then send. This request checks them against `webhook_secret`.\n\nSign the bytes you received, never re-serialised JSON, and compare with a constant-time function in your own code."},"response":[]},{"name":"6. Update this webhook","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Status code is 200\", function () {","    pm.response.to.have.status(200);","});","","const webhook = pm.response.json();","pm.test(\"The change was saved\", function () {","    pm.expect(webhook.description).to.eql(\"Updated from the Postman collection\");","});","pm.test(\"An update never returns the signing secret\", function () {","    pm.expect(webhook).to.not.have.property(\"secret\");","});"]}}],"request":{"method":"PUT","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"description\": \"Updated from the Postman collection\"\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"{{base_url}}/v1/webhooks/{{webhook_id}}","host":["{{base_url}}"],"path":["v1","webhooks","{{webhook_id}}"]},"description":"Changes a webhook. Send only the fields you are changing: `url`, `events`, `active` (`false` pauses deliveries) or `description`.\n\nUpdating needs an account with live webhooks enabled. A `404 Not Found` here means yours is not: write to api@trackcourier.io."},"response":[]},{"name":"7. Rotate your signing secret (changes it for EVERY webhook on your account)","event":[{"listen":"prerequest","script":{"type":"text/javascript","exec":["// Rotation replaces the secret for every webhook on the account, live ones included,","// and a delivery your receiver rejects is not replayed. So without your explicit yes","// in rotate_signing_secret_even_though_it_changes_every_webhook_on_my_account","// it sends only when the account has no LIVE webhook: then nothing live can break.","// A refusal comes BEFORE the skip: skipRequest() ends the script, also in a callback.","const receiverUrl = (pm.variables.get(\"webhook_url\") || \"\").trim();","const consented = pm.variables.get(\"rotate_signing_secret_even_though_it_changes_every_webhook_on_my_account\") === \"yes\";","if (!consented && !receiverUrl) {","    console.log(\"TRACKCOURIER_WEBHOOKS_FOLDER_SKIPPED_WEBHOOK_URL_NOT_SET: set webhook_url to your receiver's public https:// URL to run the Webhooks folder.\");","    pm.execution.skipRequest();","} else if (!consented) {","    pm.sendRequest({","        url: pm.variables.replaceIn(\"{{base_url}}/v1/webhooks\"),","        method: \"GET\",","        header: { \"X-API-Key\": pm.variables.get(\"api_key\") }","    }, function (err, res) {","        let reason = null;","        if (err || res.code !== 200) {","            reason = \"Could not list your webhooks, so cannot confirm that none is live. Send this request again.\";","        } else {","            const live = res.json().filter(function (w) { return !String(w.id).startsWith(\"test_\"); });","            if (live.length) {","                reason = \"Your account has \" + live.length + \" live webhook(s), and rotating changes the secret for \" +","                    \"all of them at once. To rotate anyway, set the collection variable \" +","                    \"rotate_signing_secret_even_though_it_changes_every_webhook_on_my_account to yes and send again.\";","            }","        }","        if (reason) {","            console.log(reason);","            pm.test(\"Not sent: rotation was not confirmed\", function () { throw new Error(reason); });","            pm.execution.skipRequest();","        }","    });","}"]}},{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Secret rotated (200)\", function () {","    pm.response.to.have.status(200);","});","","const rotated = pm.response.json();","pm.test(\"The new secret is in the response\", function () {","    pm.expect(rotated.secret).to.be.a(\"string\").and.not.be.empty;","});","pm.collectionVariables.set(\"webhook_secret\", rotated.secret);","console.log(\"New signing secret stored in webhook_secret. Accept BOTH the old and the new secret for at least 15 minutes: signing servers switch over one by one.\");"]}}],"request":{"method":"POST","header":[],"url":{"raw":"{{base_url}}/v1/webhooks/rotate-secret","host":["{{base_url}}"],"path":["v1","webhooks","rotate-secret"]},"description":"Replaces your signing secret and returns the new one, once. **It applies to every webhook on your account at once**, live ones included.\n\nThe changeover is not instant: for a while some deliveries are still signed with the old secret. Be ready to accept the new secret before you rotate, accept BOTH for at least 15 minutes after, and drop the old one only once 15 minutes pass without a delivery that needs it.\n\nWithout your yes in `rotate_signing_secret_even_though_it_changes_every_webhook_on_my_account` it sends only when your account has no live webhook, so nothing live can break. Otherwise set that variable to `yes` to confirm; it stays set until you change it.\n\nRotating needs an account with live webhooks enabled. A `404 Not Found` here means yours is not: write to api@trackcourier.io."},"response":[]},{"name":"8. Delete this webhook","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"Webhook deleted (204)\", function () {","    pm.response.to.have.status(204);","});","pm.collectionVariables.set(\"webhook_id\", \"\");"]}}],"request":{"method":"DELETE","header":[],"url":{"raw":"{{base_url}}/v1/webhooks/{{webhook_id}}","host":["{{base_url}}"],"path":["v1","webhooks","{{webhook_id}}"]},"description":"Deletes the webhook. Your account can hold up to 5 test webhooks at a time, so delete the ones you are done with."},"response":[]}]}],"event":[{"listen":"prerequest","script":{"type":"text/javascript","exec":["// Requests that act on {{webhook_id}}. Every refusal comes BEFORE the skip: skipRequest()","// ends this script.","// - webhook_url unset: you are not testing webhooks. Skip QUIETLY with the marker line, even","//   if an earlier run left a webhook_id behind, so a run with only api_key stays green.","// - no id stored by \"Webhooks / 1. Create a test webhook\": that request failed; say so.","// - an environment or global webhook_id differs from the stored one: pm.variables reads it","//   FIRST, so the request would go to that other webhook. Refuse.","// - not a test_ id: this folder never updates, fires at or deletes a live webhook.","if (pm.request.url.toString().includes(\"{{webhook_id}}\")) {","    const receiverUrl = (pm.variables.get(\"webhook_url\") || \"\").trim();","    const storedId = pm.collectionVariables.get(\"webhook_id\") || \"\";","    const resolvedId = pm.variables.get(\"webhook_id\") || \"\";","    let refusal = null;","    if (!receiverUrl) {","        console.log(\"TRACKCOURIER_WEBHOOKS_FOLDER_SKIPPED_WEBHOOK_URL_NOT_SET: set webhook_url to your receiver's public https:// URL to run the Webhooks folder.\");","        pm.execution.skipRequest();","    } else if (!storedId) {","        refusal = [\"Not sent: no webhook yet\", \"No webhook to act on: \\\"Webhooks / 1. Create a test webhook\\\" did not create one in this run.\"];","    } else if (resolvedId !== storedId) {","        refusal = [\"Not sent: webhook_id is overridden\", \"An environment or global variable named webhook_id overrides the one request 1 stored, so this request would go to a different webhook. Remove that variable.\"];","    } else if (!storedId.startsWith(\"test_\")) {","        refusal = [\"Not sent: not a test webhook\", \"This folder only acts on the test webhook request 1 created; \" + storedId + \" is not one.\"];","    }","    if (refusal) {","        console.log(refusal[1]);","        pm.test(refusal[0], function () { throw new Error(refusal[1]); });","        pm.execution.skipRequest();","    }","}"]}},{"listen":"test","script":{"type":"text/javascript","exec":["// Every request here succeeds with a 2xx on its happy path. The exact code is","// checked by the requests where it is not 200: the create (201) and the delete (204).","pm.test(\"Status code is 2xx\", function () {","    pm.expect(pm.response.code).to.be.within(200, 299);","});","","if (pm.response.code !== 204) {","    pm.test(\"Response is JSON\", function () {","        pm.response.to.be.json;","    });","}"]}}],"variable":[{"key":"base_url","value":"https://api.trackcourier.io","type":"string"},{"key":"api_key","value":"YOUR_API_KEY_HERE","type":"string"},{"key":"dtdc_tracking","value":"R1000482882","type":"string"},{"key":"bluedart_tracking","value":"17496195555","type":"string"},{"key":"delhivery_tracking","value":"319430299","type":"string"},{"key":"speedpost_tracking","value":"JI381296111IN","type":"string"},{"key":"indiapost_tracking","value":"EB123456789IN","type":"string"},{"key":"webhook_url","value":"","type":"string"},{"key":"webhook_id","value":"","type":"string"},{"key":"webhook_secret","value":"","type":"string"},{"key":"rotate_signing_secret_even_though_it_changes_every_webhook_on_my_account","value":"no","type":"string"},{"key":"webhook_last_delivery_before_test_fire","value":"","type":"string"},{"key":"webhook_received_body","value":"","type":"string"},{"key":"webhook_received_signature","value":"","type":"string"},{"key":"webhook_signature_example_secret","value":"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff","type":"string"},{"key":"webhook_signature_example_body","value":"{\"data\":{\"checkpoints\":[{\"activity\":\"Delivered\",\"checkpoint_state\":\"delivered\",\"courier_name\":\"DTDC\",\"date\":\"18-Sep-2026\",\"location\":\"New Delhi\",\"time\":\"17:08\"}],\"courier\":\"dtdc\",\"delivered_date\":null,\"destination_city\":null,\"enqueued_at\":\"2026-09-18T09:14:22Z\",\"most_recent_status\":\"Delivered\",\"origin_city\":null,\"status\":\"delivered\",\"tracking_number\":\"7X118822611\"},\"enqueued_at\":\"2026-09-18T09:14:22Z\",\"event\":\"tracking.delivered\"}","type":"string"},{"key":"webhook_signature_example_expected","value":"sha256=3b2224c518f23286c9e9d37da8473096537a13972b3f658756effad93844a972","type":"string"}]}