# AfterShip

Source: https://developers.swell.is/app-guides/aftership

The AfterShip app sends your Swell shipments to AfterShip as trackings, and brings their delivery status back to the order. When a shipment gets a tracking number, AfterShip starts tracking the parcel with the carrier and, if you've set them up in AfterShip, sends its own tracking notifications to the customer.

Status changes reach Swell through a signed AfterShip webhook, and the app also refreshes trackings still in transit every hour. Each order's **AfterShip** tab shows the delivery status, estimated delivery and latest scan of its shipments.

## How it works

| When | What the app does |
| --- | --- |
| A shipment is created, or its tracking number or carrier changes | Creates an AfterShip tracking with the order, destination and customer, if Send shipments to AfterShip is on. A new tracking number or carrier replaces the old tracking. |
| A shipment is canceled or deleted | Deletes its AfterShip tracking, if Delete trackings of canceled shipments is on. |
| AfterShip sends a tracking update to the webhook | Checks the signature, loads the tracking from AfterShip, and saves its status on the shipment and on the order's AfterShip tab. |
| Every hour | Reloads up to 50 trackings that aren't delivered or expired, from shipments created in the last 60 days. |
| You run Send existing shipments | Sends the shipments from the last 7, 30 or 90 days that the app hasn't sent yet, in the background. |
| You run Test connection | Checks the API key, lists the couriers turned on in AfterShip, and says whether the webhook secret is set. |

Nothing is sent to AfterShip while **Enabled** is off.

## Before you begin

- An AfterShip account on a plan that includes API access.
- An AfterShip API key with the **API key** authentication type. AES and RSA signature keys aren't supported. A key with the default permissions works. If you restrict it, allow reading and writing trackings. You create the key in step 1.
- Your AfterShip webhook secret, from **Settings > Webhooks** in AfterShip. Without it, the app rejects every webhook, and statuses reach Swell only with the hourly refresh.
- A Swell public key for each environment, from **Developer > API keys**, to build the webhook URL.

## Set up

Install AfterShip from the Swell App Store, and set it up in your test environment before the live one.

**1. Create an API key**

In AfterShip, go to **Settings > API keys** and click **Create API key**. Enter a name, choose the **API key** authentication type, and keep the default permissions, or allow reading and writing trackings. Save the key and copy it.

**2. Copy the webhook secret**

In AfterShip, go to **Settings > Webhooks** and copy the webhook secret.

**3. Add them to the app**

In Swell, go to **Apps > AfterShip > Settings** and enter the **API key**. **Enabled** is on by default. On the **Tracking updates** tab, enter the **Webhook secret**. On the **Shipments** tab, choose whether to share customer contact details and delete the trackings of canceled shipments, and set the **Tags**. Click **Save settings**.

**4. Test the connection**

Choose **Actions > Test connection**. The message starts with "Connected to AfterShip." and lists the couriers turned on in AfterShip, then says "Webhook secret is set." If the message reports an error, see Troubleshooting below.

**5. Add the webhook in AfterShip**

Start from the **Webhook URL** on the **Tracking updates** tab:

```text
https://{store-id}:{public-key}@{store-id}.swell.store/functions/aftership/aftership-webhook
```

Replace `{store-id}`, in both places, with your store ID, the subdomain of your `swell.store` address, and `{public-key}` with a Swell public key from **Developer > API keys**: the test key for the test environment, the live key for live. Never use a secret key here.

In AfterShip, go to **Settings > Webhooks**, add the URL, and choose the tracking updates to send to it. Save the webhook secret in Swell first: until it's saved, the app answers every webhook with a `401` error.

**6. Send existing shipments**

To send shipments created before you installed the app, choose **Actions > Send existing shipments**, choose 7, 30 or 90 days, and click **Send shipments**. The run shows on the app's **Workflows** tab as **Import shipments**. Skip this step if you don't need past shipments in AfterShip.

**7. Test with a shipment**

On a test order, add a shipment with a real tracking number. AfterShip shows a tracking titled with the order number, such as "Order #1042", with the `swell` tag. On the order, the **AfterShip** tab shows the **Delivery status** and a line for the shipment under **Shipments**. The logs show "AfterShip: shipment … sent as tracking …".

When AfterShip has a status change for the parcel, it sends it to the webhook, and the tab shows the new status. The logs show "AfterShip: shipment … is" followed by AfterShip's status.

**8. Go live**

Settings are saved separately for each environment. In the live environment, repeat steps 3 to 5, and build the webhook URL with your live public key. If both environments use the same AfterShip account, add both URLs in AfterShip. Each environment ignores updates for trackings that none of its shipments has.

## Using AfterShip

### Shipments sent to AfterShip

With **Send shipments to AfterShip** on, the app sends a shipment when it's created with a tracking number, and when its tracking number or carrier changes. Drafts, canceled shipments and shipments without a tracking number aren't sent. A shipment without a tracking number is sent once it gets one. Changes to other shipment details, such as notes or items, aren't sent.

Each tracking has:

- **Tracking number:** the shipment's tracking number, or the first package's when the shipment has none.
- **Courier:** from the shipment's carrier. See Couriers below.
- **Title and order:** "Order #" and the order number as the title, with the order's ID, number and date.
- **Ship date:** when the shipment was created.
- **Destination:** the country, state, city and postal code of the shipment's destination, or of the order's shipping address.
- **Tags:** the **Tags** setting, `swell` by default.
- **Customer:** with **Share customer contact** on, the customer's name, email and phone number, when there's an email or a phone number. The email is the account's, or the billing email. Phone numbers are sent in international format, such as `+15035550142`, and left out when they aren't valid.
- **Custom fields:** the Swell shipment ID and order ID, which the app uses to find the shipment when AfterShip sends an update.

If AfterShip already has a tracking with the same number, for example one you added in AfterShip, the app links the shipment to it instead of creating a second one.

When the tracking number or carrier changes, the app creates a new tracking and deletes the old one. If AfterShip refuses a shipment, for example because the tracking number isn't valid, the reason shows on the order's **AfterShip** tab, and the shipment isn't sent again until its tracking number or carrier changes.

### Couriers

The app sends the shipment's carrier as the AfterShip courier, in lowercase with spaces and underscores turned into hyphens. Carriers whose AfterShip name differs are translated, such as `dhl_express` to `dhl`, `canada_post` to `canada-post` and `royal_mail` to `royal-mail`. Carriers set by a shipping app use the carrier name instead.

When the shipment has no usable carrier, or AfterShip doesn't accept the courier, the tracking is sent without one, and AfterShip detects the courier from the tracking number. Detection works best with the couriers you ship with turned on in AfterShip. If AfterShip picks the wrong courier, correct it in AfterShip.

### Delivery status in Swell

AfterShip sends status changes to the webhook. The app loads the tracking from AfterShip with your API key, then saves its status, latest scan with its location, estimated delivery, delivery date and tracking page on the shipment, and updates the order's **AfterShip** tab. Updates older than the one already saved are ignored.

The statuses are AfterShip's: **Pending**, **Info received**, **In transit**, **Out for delivery**, **Delivery attempt failed**, **Ready for pickup**, **Delivered**, **Exception** and **Expired**. The app changes only its own fields. The order's status and the shipment's own details stay as they are.

### The order's AfterShip tab

Each order gets an **AfterShip** tab with these read-only fields:

| Field | What it shows |
| --- | --- |
| Delivery status | The status all the order's shipments share. When they differ: Exception or Delivery attempt failed if any shipment has it, else Partly delivered (n of m) when some are delivered, else the first status that isn't Pending. |
| Estimated delivery | The latest estimate of the shipments not delivered yet, as AfterShip gives it, a date or a date and time. |
| Delivered | When the last shipment was delivered, once all of them are. |
| Tracking page | The AfterShip tracking page of the first shipment not delivered yet, or of the shipment updated last. |
| Shipments | One line per shipment: the shipment number, courier and tracking number, status, latest scan and tracking page. |

A shipment AfterShip refused, or whose tracking was deleted in AfterShip, shows "Not tracked:" with the reason instead of a status. Canceled shipments and shipments the app hasn't sent aren't listed.

### Refreshing

Every hour, at 15 minutes past, the app reloads up to 50 trackings from AfterShip, those checked longest ago first, in case a webhook was missed. It checks shipments created in the last 60 days that aren't canceled, while their status isn't **Delivered** or **Expired**. Webhooks still update the shipment after that. There's no action to refresh right away.

If a tracking was deleted in AfterShip, the refresh removes it from the shipment, and the order's **AfterShip** tab shows "Not tracked: The tracking was deleted in AfterShip." The shipment isn't sent again until its tracking number or carrier changes.

### Canceled and deleted shipments

With **Delete trackings of canceled shipments** on, canceling or deleting a shipment in Swell deletes its AfterShip tracking, so AfterShip stops tracking the parcel and sending notifications for it. A tracking that's already gone in AfterShip counts as deleted. This also deletes a tracking you created in AfterShip that the shipment was linked to.

A canceled shipment's tracking is cleared from it, so if the shipment is restored, it's sent again. With the setting off, the tracking stays in AfterShip.

### Sending existing shipments

To send shipments created before the app was installed, or while sending was off, go to **Apps > AfterShip**, choose **Actions > Send existing shipments**, choose **7 days**, **30 days** or **90 days** in **Shipments created in the last**, and click **Send shipments**.

- The run sends each shipment the same way as a shipment event, with the current settings, 50 shipments at a time, 3 at once. Canceled shipments aren't loaded.
- Runs are listed on the app's **Workflows** tab as **Import shipments**, with their status. Click a run to see its steps.
- When it finishes, the logs show the totals: sent, linked to existing trackings, already sent, skipped and failed. Shipments AfterShip refused before count as already sent until their tracking number or carrier changes. Drafts, shipments without a tracking number and shipments whose order isn't found count as skipped.
- Running it again doesn't create duplicates. Shipments already sent are counted as already sent.
- If **Enabled** or **Send shipments to AfterShip** is off, or the API key is empty, the run doesn't start and fails with the reason. The settings are read again before each 50 shipments, so turning one off during a run stops it.
- If AfterShip can't be reached, rate-limits the app or has a server error, each group of 50 is tried up to 5 more times before the run fails.

**Send existing shipments** is in the Actions menu only while **Send shipments to AfterShip** is on.

### In the dashboard

| Where | What it's for |
| --- | --- |
| Apps > AfterShip > Settings | The API key, Enabled, and the Shipments and Tracking updates tabs, with the webhook secret and the webhook URL pattern. |
| Apps > AfterShip > Actions | Test connection, and Send existing shipments while Send shipments to AfterShip is on. |
| Apps > AfterShip > Workflows | The runs of Send existing shipments, listed as Import shipments, with their status. |
| An order's AfterShip tab | Delivery status, Estimated delivery, Delivered, Tracking page and Shipments. |
| Developer > Console > Logs | Each shipment sent or linked, each tracking deleted, rejected webhooks, and errors. |

## Settings

Settings are in **Apps > AfterShip > Settings** and are saved separately for each environment.

**API key** and **Enabled** are at the top. The other settings are on the **Shipments** and **Tracking updates** tabs.

| Setting | Description | Default |
| --- | --- | --- |
| API key | From AfterShip > Settings > API keys, with the API key authentication type. A restricted key needs to read and write trackings. Masked on the settings page. Required. | None |
| Enabled | Turns off everything the app sends to AfterShip, the hourly refresh and the webhook updates, without uninstalling it. Webhooks still need a valid signature. Test connection still works while it's off. | On |
| Shipments > Send shipments to AfterShip | Creates a tracking when a shipment gets a tracking number, and a new one when the number or carrier changes. Send existing shipments needs it. | On |
| Shipments > Share customer contact | Sends the customer's name, email and phone number with each tracking, so AfterShip notifications can reach them. | On |
| Shipments > Delete trackings of canceled shipments | Deletes the AfterShip tracking when the shipment is canceled or deleted in Swell. | On |
| Shipments > Tags | Comma-separated tags added to every tracking in AfterShip. | swell |
| Tracking updates > Webhook secret | From AfterShip > Settings > Webhooks. Webhooks without a valid signature are rejected, and while it's empty, every webhook is. Masked on the settings page. | None |
| Tracking updates > Webhook URL | Read only. The URL pattern to add in AfterShip, with your store ID and a public key filled in. | The URL pattern |

## Limitations

- Each shipment is one tracking, with the shipment's tracking number or its first package's. Other package numbers aren't sent.
- Only a new tracking number or carrier is sent. Later changes to the shipment, order or customer, such as a new address, don't reach AfterShip.
- Customer notifications come from AfterShip, if you've set them up there. The app doesn't send emails.
- Without the webhook, statuses reach Swell only with the hourly refresh, up to 50 trackings an hour. Trackings of shipments older than 60 days aren't refreshed.
- If the test and live environments use the same AfterShip account and send the same tracking number, both shipments link to one tracking, and canceling or deleting either shipment deletes it.
- A tracking deleted in AfterShip isn't sent again until the shipment's tracking number or carrier changes.
- If AfterShip can't be reached, rate-limits the app or has a server error while a shipment is sent or deleted, Swell runs the function again later, at growing intervals. If it keeps failing for about 4 days, Swell turns off the function and emails the store's admins. Reinstall the app to turn it back on.
- While AfterShip rejects the API key, webhooks are acknowledged without saving the update. The hourly refresh catches up once the key is fixed.

## Troubleshooting

**Test connection** and **Send existing shipments** show their results in the dashboard, and errors about a shipment show on its order's **AfterShip** tab. The app also logs each shipment it sends and each error. In the Swell dashboard, the logs are under **Developer > Console > Logs**.

### "Add the API key and save the settings first."

Enter the **API key**, click **Save settings**, and run **Test connection** again.

### "AfterShip rejected the API key (401 Unauthorized). …"

Copy the key again from **Settings > API keys** in AfterShip, and check that its authentication type is **API key**. AES and RSA signature keys aren't supported. Paste it, click **Save settings**, and test again.

### "AfterShip refused … (403 Forbidden). …"

The message names the call AfterShip refused. Check that the API key is allowed to read and write trackings, and that your AfterShip plan includes API access.

### "AfterShip is unavailable (…). Try again later."

AfterShip had a server error. "Couldn't reach AfterShip (…). Try again later." means the request didn't get through, and "AfterShip rate limit reached (429). Try again in a minute." means AfterShip limited the app's requests. Try again in a minute.

### A shipment isn't in AfterShip

Check that **Enabled** and **Send shipments to AfterShip** are on, and that the shipment has a tracking number and isn't a draft or canceled. Shipments created before the app was installed, or while sending was off, are sent when their tracking number or carrier changes, or with **Send existing shipments**. The logs show "AfterShip: missing API key in app settings" when the key is empty, and "not sent:" with the reason when AfterShip refused the shipment.

### "Not tracked: AfterShip refused POST /trackings: …"

AfterShip refused the shipment, and the reason follows the message, for example an invalid tracking number. Fix the shipment's tracking number or carrier in Swell, and the app sends it again.

### "Not tracked: The tracking was deleted in AfterShip."

The tracking was deleted in AfterShip. To track the shipment again, change its tracking number or carrier in Swell, or add the tracking in AfterShip yourself.

### The AfterShip tab isn't updated

Check that **Enabled** is on, and check the webhook in AfterShip under **Settings > Webhooks**: the URL needs your store ID and a public key for the same environment. Run **Test connection** to check that the webhook secret is set. Without webhooks, the status changes with the next hourly refresh, for shipments created in the last 60 days.

### "Webhook secret is not set in the app settings"

AfterShip's webhook gets a `401` response with this message while **Webhook secret** is empty. Copy the secret from **Settings > Webhooks** in AfterShip, enter it on the **Tracking updates** tab, and click **Save settings**.

### "Invalid webhook signature"

AfterShip's webhook gets a `401` response with this message when the request isn't signed with the **Webhook secret** saved in Swell. Copy the secret again from AfterShip, save it, and check that the webhook is from the same AfterShip account as the API key.

### "Function app.aftership.aftership-webhook not found"

AfterShip's webhook gets a `404` response with this message when the URL has no public key, a key from another store, or a key for an environment where the app isn't installed. Check the `{store-id}` and `{public-key}` parts of the URL. If your AfterShip plan includes custom webhook headers, you can instead add a header with the key `Authorization` and your Swell public key as its value.

### "Import not started: …"

The run fails without sending anything, with "Import not started: the AfterShip app is turned off in its settings." when **Enabled** is off, "Import not started: Send shipments to AfterShip is off in the app settings.", or "Import not started: add the API key in Apps → AfterShip." Fix the setting, click **Save settings**, and run it again.

### "Import stopped: …"

**Enabled** or **Send shipments to AfterShip** was turned off, or the API key was removed, during the run. The message gives the reason and the totals until then. Fix the setting and run it again. Shipments already sent aren't sent twice.

### "Import failed on page …"

AfterShip refused the run, for example because of a rejected API key or a 403. The message gives the same explanation as **Test connection** and the totals until then. Fix the cause and run it again.

### "Import finished, but AfterShip refused all … shipments it was sent; …"

AfterShip didn't accept any of the shipments. The reason for each is on its order's **AfterShip** tab, and the logs list each one with "not sent:".

### Send existing shipments isn't in the Actions menu

Turn on **Send shipments to AfterShip** and click **Save settings**.

## Uninstalling

Uninstalling the app stops sending shipments, deleting the trackings of canceled shipments and refreshing statuses. It removes the app's functions and settings page, the **AfterShip** tab on orders, and the **Test connection** and **Send existing shipments** actions. The webhook stops working, so AfterShip's deliveries to it fail with a `404`. Uninstalling applies to the environment you are in, so remove the app from the test and live environments separately.

The AfterShip values saved on your shipments and orders are kept, and show again if you reinstall the app. Trackings stay in AfterShip, and AfterShip keeps tracking them and sending its notifications. The app isn't notified when it's uninstalled, so delete the webhook in AfterShip under **Settings > Webhooks**, and the API key under **Settings > API keys** if you no longer need it. To pause the app without uninstalling it, turn off **Enabled**.

## For developers

| Function | Runs on | What it does |
| --- | --- | --- |
| shipment-sync | shipment.created, shipment.updated, shipment.canceled and shipment.deleted | Creates or replaces the tracking on created and updated, and deletes it on canceled and deleted. Skips updates that change none of tracking_code, carrier, carrier_name, packages, canceled and draft. |
| aftership-webhook | A public POST route | Checks the signature, reloads the tracking from AfterShip and saves it on its shipment and order. |
| refresh-tracking | Every hour, at minute 15 | Reloads up to 50 open trackings, least recently checked first, and saves their status. |
| test-connection | The Test connection action | Lists the couriers turned on in AfterShip and checks whether the webhook secret is set. |
| import-shipments | The Send existing shipments action | A workflow. Reads shipments 50 at a time in id order, one step per page, each step retried up to 5 times with exponential backoff from 30 seconds. |

The app stores its fields on shipments under `$app.aftership`: `tracking_id`, `synced`, `tracking_number`, `courier`, `status`, `status_detail`, `last_event`, `last_event_at`, `estimated_delivery`, `delivered_at`, `tracking_url`, `updated_at`, `checked_at` and `error`. On orders, it stores `aftership_status`, `aftership_tracking_url`, `aftership_estimated_delivery`, `aftership_delivered_at` and `aftership_shipments` under `$app.aftership`.

Calls go to the AfterShip Tracking API version `2026-07`, at `https://api.aftership.com/tracking/2026-07`, with the key in the `as-api-key` header:

- `POST /trackings` creates the tracking. On meta code `4003`, tracking already exists, the app looks it up with `GET /trackings?tracking_numbers=…&slug=…` and links the shipment to it. On `4010` or `4012`, the courier slug was refused, it's sent again without `slug`.
- `GET /trackings/{id}` for the webhook and the hourly refresh. Meta code `4004` means the tracking was deleted. A plain 404 isn't treated as deleted.
- `DELETE /trackings/{id}` for canceled and deleted shipments, and for the old tracking when the number changes. A tracking that's already gone counts as deleted.
- `GET /couriers` for Test connection.

The tracking body has `tracking_number`, `slug`, `title`, `order_id`, `order_number`, `order_date`, `order_id_path`, `tracking_ship_date`, `destination_country_region`, `destination_state`, `destination_city`, `destination_postal_code`, `shipment_tags`, `custom_fields` with `swell_shipment_id` and `swell_order_id`, and `customers`. Dates are sent as `YYYY-MM-DDTHH:mm:ss+00:00`, without milliseconds. `order_id_path` is the store's `admin_url` plus `/orders/{id}`, and is left out in Send existing shipments runs, whose requests have no `admin_url`.

The country is converted from ISO alpha-2 to alpha-3 with country-iso-2-to-3. Phone numbers are parsed with libphonenumber-js's `parsePhoneNumberWithError`, using the destination phone, then the order's shipping phone, then the account's phone, with the destination country as the default country. Numbers that fail to parse or `isValid()` are left out, and the rest are sent as E.164.

The webhook reads the `aftership-hmac-sha256` header, the base64 HMAC-SHA256 of the raw request body keyed with the webhook secret, and compares it in constant time. It reads only `msg.id` from the payload, then loads the tracking with `GET /trackings/{id}`. The shipment is found by the tracking's `custom_fields.swell_shipment_id` when that shipment has the same `tracking_id`, else by `$app.aftership.tracking_id`. Updates whose `updated_at` is older than the saved one are skipped. Network errors, 429 and 5xx from AfterShip, and errors saving to Swell, answer `503`. Other AfterShip errors are logged and acknowledged.

`synced` holds the courier slug and tracking number last sent, as `usps:9400…`, or `auto:` plus the number when there's no slug. A shipment is sent only when this key changes, and errors about the shipment are saved with the key, so they aren't retried on every update. In the event functions, network errors, 429 and 5xx are rethrown, so Swell retries the function. A retry is safe because an existing tracking is linked, not duplicated. 401 and 403 errors are logged and not saved.

The hourly refresh matches shipments by `$app.aftership.status` in the open statuses, `canceled` not `true` and `date_created` in the last 60 days, sorted by `$app.aftership.checked_at`. The import's step output holds only flags and counts, never the API key or webhook secret.

## Support

Swell builds and maintains the AfterShip app. For help with the app, email support@swell.is. For questions about your AfterShip account, couriers, trackings or notifications, contact AfterShip.
