Skip to content

click-and-drop-api

CI Tests PyPI version PyPI Downloads Ruff

This is an inofficial Python client for the Royal Mail Click & Drop API. The Click & Drop API allows you to import your orders, retrieve your orders and generate labels.

Read about Royal Mail's Click & Drop API:

Links for this package:

Installation

pip install click_and_drop_api

API Reference

This package has extensive documentation for the API Reference, which can be found here.

API Key

You need an API key to use the Click & Drop API. You can get this key here:

  1. Register an account at parcel.royalmail.com
  2. Login at auth.parcel.royalmail.com
  3. Go to Settings → Integrations → Add a new integration → Click & Drop API
  4. Fill out the details and save/update them.
  5. Copy the Click & Drop API authorisation key
export API_KEY="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"

The API key will be used on the examples below to authenticate the requests.

OBA

Some endpoints like generating labels require an OBA (Online Business Account). Label generation is available for OBA accounts only:

  • Private accounts pay as the label is generated.
  • OBA accounts pay monthly via invoice.

This only OBA can generate labels on request.

  1. Apply for a Royal Mail Business Account (OBA).
  2. Link your OBA to Click & Drop.
  3. Fill in required account information and make sure you follow the law.

Examples

This sections guides you through some examples. Export the API_KEY of your account to run the examples. The examples use the simple API that is based on the generated API and reduces the amount of code.

Retrieve the version

Version API Documentation

Retrieving the version is useful to understand if you can use the API without authentication.

#!/usr/bin/env python
from click_and_drop_api.simple import ClickAndDrop
import os

# navigate to https://business.parcel.royalmail.com/settings/channels/
# Configure API key authorization: Bearer
API_KEY = os.environ["API_KEY"]

api = ClickAndDrop(API_KEY)

version = api.get_version()

# https://api.parcel.royalmail.com/#tag/Version
print("commit:", version.commit)
print("build:", version.build)
print("release:", version.release)
print("release date:", version.release_date)

Output:

commit: 9236a82ff38de629d7b987a4d291d81c34fdd531
build: 20260107.6
release: Release-20260107.3
release date: 2026-01-22 10:20:54+00:00

View specific orders

Orders API Documentation

The image below shows orders that were created as examples.

Example orders for the script to retrieve

The script below retrieves information about these orders, by id (int) and by reference (str).

#!/usr/bin/env python
from click_and_drop_api.simple import ClickAndDrop
import os

# navigate to https://business.parcel.royalmail.com/settings/channels/
# Configure API key authorization: Bearer
API_KEY = os.environ["API_KEY"]

api = ClickAndDrop(API_KEY)

example_order_id = 1002
example_order_reference = "my-ref-9999"

orders = api.get_orders([example_order_id, example_order_reference])

for order in orders:
    print("Order Identifier:", order.order_identifier)
    print("Order Reference:", order.order_reference)
    print("Order Date:", order.order_date)
    print("Order Printed On:", order.printed_on)
    print("Order Manifested On:", order.manifested_on)
    print("Order Shipped On:", order.shipped_on)
    for package in order.packages:
        print("\tPackage Number:", package.package_number)
        print("\tPackage Tracking Number:", package.tracking_number)

Output:

Order Identifier: 1002
Order Reference: example-order-20260213211828
Order Date: 2026-02-13 21:18:28.936246
Order Printed On: None
Order Manifested On: None
Order Shipped On: None
    Package Number: 1
    Package Tracking Number: None
Order Identifier: 1003
Order Reference: my-ref-9999
Order Date: 2026-02-09 20:09:19.587738
Order Printed On: None
Order Manifested On: None
Order Shipped On: None
    Package Number: 1
    Package Tracking Number: None

View a single order

#!/usr/bin/env python
"""Print details of a single order by order number or reference.

Usage::

    python -m click_and_drop_api.examples.view_order <order_id_or_reference>
"""

import os
import sys

from click_and_drop_api.simple import ClickAndDrop

API_KEY = os.environ["API_KEY"]

if len(sys.argv) != 2:
    print(
        "Usage: python -m click_and_drop_api.examples.view_order <order_id_or_reference>"
    )
    sys.exit(1)

identifier = sys.argv[1]
# treat numeric strings as integer order IDs
if identifier.isdigit():
    identifier = int(identifier)

api = ClickAndDrop(API_KEY)
order = api.get_order(identifier)

if order is None:
    print(f"Order not found: {identifier!r}")
    sys.exit(1)

print(f"Order identifier : {order.order_identifier}")
print(f"Order reference  : {order.order_reference}")
print(f"Created on       : {order.created_on}")
print(f"Order date       : {order.order_date}")
print(f"Printed on       : {order.printed_on}")
print(f"Manifested on    : {order.manifested_on}")
print(f"Shipped on       : {order.shipped_on}")
print(f"Tracking number  : {order.tracking_number}")

if order.packages:
    print(f"Packages ({len(order.packages)}):")
    for pkg in order.packages:
        print(f"  #{pkg.package_number}  tracking: {pkg.tracking_number}")

Create and delete orders

Orders API Documentation

The script below creates a new order and then deletes it.

#!/usr/bin/env python
"""Create an order and download it.

On CI, also delete the order again.
"""

from pprint import pprint
from click_and_drop_api.simple import (
    ClickAndDrop,
    CreateOrder,
    RecipientDetails,
    Address,
    db,
    LabelGeneration,
)
import os
from datetime import datetime, UTC

# navigate to https://business.parcel.royalmail.com/settings/channels/
# Configure API key authorization: Bearer
API_KEY = os.environ["API_KEY"]

api = ClickAndDrop(API_KEY)

# choose a new reference or else the API will reject the order
REFERENCE = "example-order-{now}".format(now=datetime.now(UTC).strftime("%Y%m%d%H%M%S"))

service = db.get(
    "letter", "OLP2" if not api.is_oba() else "BPL2"
)  # letter, 2nd class delivery

new_order = CreateOrder(
    order_reference=REFERENCE,
    is_recipient_a_business=False,
    recipient=RecipientDetails(
        address=Address(
            full_name="Nicco Kunzmann",
            company_name="",
            address_line1="Wernlas",
            address_line2="Talley",
            address_line3="",
            city="Llandeilo",
            county="United Kingdom",
            postcode="SA19 7EE",
            country_code="GB",
        ),
        phone_number="07726640000",
        email_address="niccokunzmann" + "@" + "rambler.ru",
    ),
    order_date=datetime.now(UTC),
    subtotal=float(12),  # 12 pounds
    shipping_cost_charged=float(service.gross),  # charge the same as Royal Mail
    total=float(12 + service.gross),
    currency_code="GBP",
    postage_details=service.as_postage_details(),
    packages=[service.as_package_request(weight_in_grams=80)],
    ## Label generation is only possible for OBA customers
    ## You can also only have one CreateOrder in the request if you want to generate a label
    label=LabelGeneration(
        include_label_in_response=False,
        include_cn=False,
        include_returns_label=False,
    )
    if api.is_oba()
    else None,
)

response = api.create_orders(new_order)

print(f"Orders created: {response.created_orders}")
print(f"Errors: {response.errors_count}")
for error in response.failed_orders:
    print(f"  {error.order.order_reference}: {error.errors}")

print("Getting the order from the API.")
order = api.get_order(REFERENCE)

print(f"Order Reference: {order.order_reference}")
print(f"Order Identifier: {order.order_identifier}")
pprint(order.to_dict())

# Delete the order when run in CI test
if "CI" in os.environ:
    print("Deleting order.")

    deleted_orders = api.delete_orders([REFERENCE])

    print(f"Orders deleted: {deleted_orders.deleted_orders}")
    print(f"Errors: {deleted_orders.errors}")

Output:

Orders created: [CreateOrderResponse(order_identifier=1012, order_reference='example-order-from-python-api', created_on=datetime.datetime(2026, 2, 11, 17, 32, 26, 763419, tzinfo=TzInfo(0)), order_date=datetime.datetime(2026, 2, 11, 17, 32, 26, 549854, tzinfo=TzInfo(0)), printed_on=None, manifested_on=None, shipped_on=None, tracking_number=None, packages=[CreatePackagesResponse(package_number=1, tracking_number=None)], label=None, label_errors=[], generated_documents=[])]
Errors: 0
Getting the order from the API.
Order Reference: example-order-from-python-api
Order Identifier: 1012
Deleting order.
Orders deleted: [DeletedOrderInfo(order_identifier=1012, order_reference='example-order-from-python-api', order_info=None)]
Errors: []

Avoid duplicate order references

Click & Drop accepts several orders with the same reference. Afterwards, these orders cannot be retrieved or deleted by their reference: the API answers with error code 5, "Ambiguous reference".

If the references usually do not exist yet, replace_orders() needs the fewest requests: it uploads the orders, looks the references up in one request, and only if a reference now has several orders, it deletes the older ones.

response = api.replace_orders(orders)

If an older order cannot be deleted (manifested, a printed label unless delete_printed=True), the new order is deleted instead and is in response.failed_orders.

Pass a strategy to the API to check orders before they are created. ReplaceDuplicateReferences deletes existing orders with the same references before the upload:

from click_and_drop_api.simple import ClickAndDrop, ReplaceDuplicateReferences

api = ClickAndDrop(key, create_order_strategy=ReplaceDuplicateReferences())
response = api.create_orders(orders)
  • NullStrategy uploads the orders as given. This is the default.
  • RejectDuplicateReferences does not upload orders whose reference already exists. It does not list the orders.
  • ReplaceDuplicateReferences deletes all existing orders with the same reference first, including duplicates.
  • Manifested orders cannot be deleted, so these orders are not uploaded.
  • Orders with a printed label are not deleted because that cancels the label. Pass delete_printed=True to delete them anyway.
  • Click & Drop cannot look up a reference that has several orders (error code 5), and it does not tell how many orders the reference has. So orders are listed with GET /orders until two of them are found. Click & Drop lists the newest orders first, and duplicates are usually recent. These are deleted and the reference is looked up again, until all of its orders are deleted. The listing continues where it stopped, so no page is listed twice. Pass lookback=timedelta(days=...) to not list orders uploaded before that.
  • You can also pass a strategy to a single call: api.create_orders(orders, strategy=NullStrategy()).
  • Write your own strategy by subclassing CreateOrderStrategy.

Strategies do not raise errors. Orders that are not uploaded are in response.failed_orders, next to the orders that Click & Drop rejects. All other orders are uploaded. The error codes of the strategies are negative, see StrategyErrorCode:

Code Name Reason
-1 DUPLICATE_REFERENCE The reference exists (RejectDuplicateReferences) or appears several times in the upload.
-2 ORDER_MANIFESTED An existing order with the reference is manifested.
-3 LABEL_ALREADY_PRINTED An existing order with the reference has a printed label.
-4 DELETE_FAILED Click & Drop did not delete an existing order with the reference.
  • lookup_references(references) looks up references without listing the orders. It returns a ReferenceLookup with the sets absent, unique and ambiguous (several orders), the property existent (unique | ambiguous), and the orders of the unique references.
  • find_orders_by_reference(references) returns the orders of each reference. For a reference with several orders, it stops listing orders as soon as two are found. Pass orders= an iterator of a previous search to continue it.
  • delete_orders_with_references(references) deletes all orders of the references, including duplicates. It does not delete manifested orders, nor orders with a printed label unless delete_printed=True, and returns the references that could not be deleted with the reason.
  • iter_orders(start, end) iterates over all orders page by page, from the newest to the oldest. Click & Drop filters by the upload time (created_on), not by order_date, and cuts the times off to whole seconds: start is inclusive, end is exclusive and must be at least one second after start. There is no limit on the time span.
  • iter_recent_orders(until) iterates over the orders uploaded since until, from the newest to the oldest.
  • They accept an empty list of references and then do not call Click & Drop.

Errors

All errors raised by click_and_drop_api.simple are subclasses of ClickAndDropError. InvalidWeight and InvalidDimensions are also ValueErrors. Errors of the HTTP requests are raised as click_and_drop_api.exceptions.ApiException.

Package sizes and their shipping options

Several shipping options are available for each package size. There is no API for this, so the price and delivery speed is hard coded. You can view the table when you apply postage to an order. If the values are outdated, you are welcome to update them with a pull request and a screenshot of the table on the website.

This example prints all the available package sizes and their shipping options.

#!/usr/bin/env python
"""Print package size and shipping cost with options."""

from click_and_drop_api.simple import db

# Iterate package sizes in insertion order, then list each service under it
seen: dict = {}
for _o in db:
    if _o.package_size_code not in seen:
        seen[_o.package_size_code] = _o

for package_size_code, first in seen.items():
    print("Package Code:", first.package_size_code)
    print("Package Name:", first.package_name)
    print("Package Max. Weight (grams):", first.package_max_weight_g)
    print("Package Max. Height (mm):", first.height_mm)
    print("Package Max. Width (mm):", first.width_mm)
    print("Package Max. Length (mm):", first.depth_mm)
    for option in db.for_package_size(package_size_code):
        print(
            f"\t{option.brand.ljust(14)} {option.service_code.ljust(10)} £{option.gross} \t{option.delivery_speed}"
        )

Output:

Package Code: letter
Package Name: Letter
Package Max. Weight (grams): 100
Package Max. Height (mm): 5
Package Max. Width (mm): 165
Package Max. Length (mm): 240
        Royal Mail     OLP2       £0.87         48 hour (2 working days)
        Royal Mail     OLP1       £1.70         24 hour (next working day)
        Royal Mail     OLP1SF     £3.60         24 hour (next working day)
        Royal Mail     OLP2SF     £2.77         48 hour (2 working days)
        Royal Mail     SD1OLP     £8.75         Guaranteed by 1pm next working day
        Royal Mail     SD2OLP     £11.75        Guaranteed by 1pm next working day
        Royal Mail     SD3OLP     £18.75        Guaranteed by 1pm next working day
        Royal Mail     ISOLP      £3.40 
        Royal Mail     ITSOLP     £8.50 
...

This example prints every country that accepts a given service code by testing each one via the API. Test orders are created and immediately deleted — no real shipments are made.

#!/usr/bin/env python
"""Print all countries that accept a given service code.

Uses test_shipping internally: a test order is created and immediately deleted
for each country, so no real shipments are made.

Arguments:

- service code (optional, defaults to OLP1)
"""

import os
import sys

from click_and_drop_api.simple import ClickAndDrop

API_KEY = os.environ["API_KEY"]

api = ClickAndDrop(API_KEY)

PACKAGE_SIZE = "letter"
SERVICE_CODE = "OLP1" if len(sys.argv) == 1 else sys.argv[1]

countries = api.get_countries_for_shipping(
    package_size=PACKAGE_SIZE,
    service_code=SERVICE_CODE,
    weight_in_grams=50,
)

print(f"{PACKAGE_SIZE} with {SERVICE_CODE} ships to {len(countries)} countries:")
print(", ".join(countries))

Output:

letter with OLP1 ships to 228 countries:
AD, AE, AF, AG, AI, AL, AM, AO, AR, AT, AU, AW, AZ, BA, BB, BD, BE, BF, BG, BH, BI, BJ, BM, BN, BO, BQ, BR, BS, BT, BW, BY, BZ, CA, CC, CD, CF, CG, CH, CI, CL, CM, CN, CO, CR, CU, CV, CW, CX, CY, CZ, DE, DJ, DK, DM, DO, DZ, EC, EE, EG, ER, ES, ET, FI, FJ, FK, FM, FO, FR, GA, GB, GD, GE, GF, GG, GH, GI, GL, GM, GN, GP, GQ, GR, GS, GT, GU, GW, GY, HK, HN, HR, HT, HU, ID, IE, IL, IM, IN, IO, IQ, IR, IS, IT, JE, JM, JO, JP, KE, KG, KH, KI, KM, KN, KP, KR, KW, KY, KZ, LA, LB, LC, LI, LK, LR, LS, LT, LU, LV, MA, MC, MD, ME, MG, MK, ML, MM, MN, MO, MQ, MR, MS, MT, MU, MV, MW, MX, MY, MZ, NA, NC, NE, NF, NG, NI, NL, NO, NP, NR, NZ, OM, PA, PE, PF, PG, PH, PK, PL, PM, PN, PR, PT, PW, PY, QA, RE, RO, RS, RU, RW, SA, SB, SC, SD, SE, SG, SH, SI, SK, SL, SM, SN, SR, SS, ST, SV, SX, SZ, TC, TD, TF, TG, TH, TJ, TL, TM, TN, TO, TR, TT, TV, TW, TZ, UA, UG, US, UY, UZ, VA, VC, VE, VG, VI, VN, VU, WF, WS, ZA, ZM, ZW

Check if account is OBA

Some features — such as label generation and OBA-only service codes — require an OBA (Online Business Account). This example checks whether your account is an OBA by sending a test order with an OBA service code and immediately deleting it.

#!/usr/bin/env python
"""Check whether the account is an OBA (Online Business Account).

An OBA account has access to OBA-only service codes such as TPN24 (Tracked 24).
This is detected by attempting a test order with a service code — the order
is created and immediately deleted, so no shipment is made.
"""

from click_and_drop_api.simple import ClickAndDrop
import os

# navigate to https://business.parcel.royalmail.com/settings/channels/
# Configure API key authorization: Bearer
API_KEY = os.environ["API_KEY"]

api = ClickAndDrop(API_KEY)

if api.is_oba():
    print("This is an OBA account — OBA service codes are available.")
else:
    print("This is NOT an OBA account — only standard service codes are available.")

Output:

This is NOT an OBA account — only standard service codes are available.

Create postage labels

Labels API Documentation

! Labels are only available for OBA accounts !

The script below takes an order id or reference as an argument and generates a postage label for it.

#!/usr/bin/env python
"""Generate a label for an order number.

The order has to be created first.
You can use create_order.py to create an order.

View your orders: https://business.parcel.royalmail.com/orders/

Note: Label generation is only possible for OBA customers
"""

from click_and_drop_api.simple import ClickAndDrop
import os
import sys
from pathlib import Path

if len(sys.argv) < 2:
    print(
        f"""{Path(__file__).name} [ORDER_NUMBER_OR_REFERENCE]

    ORDER_NUMBER_OR_REFERENCE: The order number or reference.
    """
    )
    print(__doc__)
    exit(0)

# navigate to https://business.parcel.royalmail.com/settings/channels/
# Configure API key authorization: Bearer
API_KEY = os.environ["API_KEY"]

api = ClickAndDrop(API_KEY)

order_id = sys.argv[1]
if order_id.isdigit():
    order_id = int(order_id)

label = api.get_label(order_id, "postageLabel", include_returns_label=False)

(Path(__file__).parent / "label.pdf").write_bytes(label)