superroute-slocker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ Superroute SDK License
2
+
3
+ Copyright (c) 2026 Superroute Inc. All rights reserved, except as granted below.
4
+
5
+ 1. Permission is granted, free of charge, to any person obtaining a copy of this software and its documentation
6
+ (the "Software") to install, use, copy and distribute the Software, including as part of their own products and
7
+ services, to connect to Superroute services, on any other platform, and in their own commercial projects.
8
+
9
+ 2. The Software may not be sold on its own, whether alone or as the main part of a product whose value lies in the
10
+ Software rather than in the product built with it.
11
+
12
+ 3. The copyright notices, this license and the attribution of the Software to Superroute may not be removed or
13
+ changed, and must be kept in every copy or substantial portion of the Software that is distributed.
14
+
15
+ 4. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
16
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
18
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19
+ IN THE SOFTWARE.
20
+
21
+ 5. Use of Superroute services through the Software is governed by the terms of those services.
@@ -0,0 +1,326 @@
1
+ Metadata-Version: 2.4
2
+ Name: superroute-slocker
3
+ Version: 0.1.0
4
+ Summary: sLocker: deliver to Superroute smart lockers. The partner locker delivery API and its webhooks.
5
+ Author: Superroute
6
+ License-Expression: LicenseRef-Superroute-SDK-License
7
+ Project-URL: Homepage, https://open.superroute.ca/developer
8
+ Keywords: superroute,smart locker,parcel locker,delivery,webhooks
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Software Development :: Libraries
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Goodneo-Solutions/slocker-releases/main/assets/slocker-logo-dark.svg">
19
+ <img src="https://raw.githubusercontent.com/Goodneo-Solutions/slocker-releases/main/assets/slocker-logo.svg" alt="sLocker" height="56">
20
+ </picture>
21
+
22
+ # sLocker
23
+
24
+ Deliver to Superroute smart lockers. sLocker is the SDK for carriers and delivery partners that put parcels into
25
+ Superroute smart lockers: create a delivery task, open the doors remotely for the driver, confirm the deposit, follow
26
+ the pickup, and receive and verify the webhooks — with a mock cabinet to rehearse all of it.
27
+ Superroute SDK License — see [LICENSE](LICENSE) and [open.superroute.ca/developer/license](https://open.superroute.ca/developer/license).
28
+
29
+ | Package | Platform | Install |
30
+ |---|---|---|
31
+ | `superroute-slocker` (PyPI) | Python 3.9+ | `pip install superroute-slocker` |
32
+ | `superroute/slocker` (Packagist) | PHP 8.1+ | `composer require superroute/slocker` |
33
+ | `ca.superroute:slocker` (Maven) | Java 11+ and Android | see [Java and Kotlin](#java-and-kotlin) |
34
+ | `ca.superroute:slocker-android` (Maven) | Kotlin coroutines and OkHttp, for Android | see [Java and Kotlin](#java-and-kotlin) |
35
+ | `github.com/Goodneo-Solutions/slocker-releases/go` | Go 1.21+ | `go get github.com/Goodneo-Solutions/slocker-releases/go` |
36
+ | `Superroute.SLocker` (NuGet) | .NET 8 | `dotnet add package Superroute.SLocker` |
37
+ | `@superrouteca/slocker` (npm) | Node 20+, browsers, Deno, Bun; TypeScript types | `npm install @superrouteca/slocker` |
38
+ | `SLocker` (Swift package) | iOS 15+, macOS 12+ | `https://github.com/Goodneo-Solutions/slocker-releases` |
39
+ | `slocker` (pub.dev) | Dart 3.5+ and Flutter | `dart pub add slocker` |
40
+
41
+ Every package of a release carries the same version and covers the same operations.
42
+
43
+ ## What it covers
44
+
45
+ The partner locker delivery API of Superroute (`/api/v1/third-party-delivery/…`), 40 operations, each a method:
46
+
47
+ - **Delivery tasks** — create (idempotent), list, read, cancel, rotate a code.
48
+ - **At the cabinet** — open the doors remotely with the delivery code, confirm (all or a partial delivery), abort,
49
+ reassign a package to another compartment, cancel the placement, reopen within the 60-second correction window,
50
+ open for pickup.
51
+ - **Scopes and locations** — the grants and sub-scopes, create / update / disable a sub-scope, the locations of a
52
+ scope, the locations near an address or a point.
53
+ - **Usage** — summary, daily, by location.
54
+ - **Carriers** — the carrier profile, intake locations and receiving warehouses, pre-advising packages into a
55
+ warehouse, the carrier's packages, cancelling, rerouting, and resetting a pickup code.
56
+ - **Mock cabinet** — create, list and delete mock lockers, force doors open or closed, take a cabinet offline, change
57
+ its features and compartment sizes, seed parcels, clear them, or declare them taken.
58
+
59
+ The operations are generated from Superroute's published OpenAPI document (`specs/partner-locker-api.json`). The same
60
+ operations exist in Superroute's GraphQL API (`specs/partner-locker-graphql.json` lists them); sLocker calls REST.
61
+
62
+ ## Signing in and choosing the provider
63
+
64
+ The API takes the bearer token of a Superroute API account (`POST /api/v1/user/login`; a token lasts a week). Pass a
65
+ token you already have, or sign in with the client, which keeps the token for the following calls:
66
+
67
+ ```js
68
+ import { SLockerClient } from '@superrouteca/slocker';
69
+
70
+ const sl = new SLockerClient({ providerId: 4611 }); // baseUrl defaults to https://api.superroute.ca
71
+ await sl.login({ email: 'api-partner@example.com', password: process.env.SR_PASSWORD });
72
+ // later: await sl.refreshToken(); await sl.rotateToken({ gracePeriodMinutes: 60 }); await sl.logout();
73
+ ```
74
+
75
+ `providerId` is your third-party delivery provider (sent as `X-Third-Party-Provider-Id`). It may be left out when the
76
+ account acts for exactly one provider; any call can name another one (`providerId` in its parameters).
77
+
78
+ ## A delivery, end to end
79
+
80
+ 1. **Create the task** with the order's own idempotency key. The answer carries `delivery_no`, the driver's
81
+ `access_code` and the recipient's `pickup_code`. A repeat with the same key and the same content answers the
82
+ original task (`replayed: true`); the same key with different content is `409 IDEMPOTENCY_CONFLICT`.
83
+ 2. **At the cabinet**, the driver types the code on the screen — or your app opens the doors remotely with
84
+ `openPartnerLockerDelivery`, which answers a `claim_token` and the compartments that opened.
85
+ 3. **Confirm** with the claim token (`confirm_all`, or `accept_partial` when some packages did not fit). For a remote
86
+ open the cabinet settles the deposit by itself once every opened door reports closed; an explicit confirm afterwards
87
+ answers the same result.
88
+ 4. **Webhooks** tell you the rest: `partner_locker.delivery.doors_opened`, `.delivered` (with the pickup code and the
89
+ compartments), `partner_locker.pickup.completed`, `.failed`, `.expired`, `.cancelled`, `.correction_reopened`.
90
+
91
+ ```js
92
+ const created = await sl.api.createPartnerLockerDelivery({
93
+ idempotencyKey: 'FOOD-20260714-1001-v1', // your own, stable for this order
94
+ body: {
95
+ external_order_id: 'FOOD-20260714-1001',
96
+ platform_scope_group_id: 12,
97
+ recipient: { phone: '+14165550123' },
98
+ package_count: 2,
99
+ },
100
+ });
101
+ const { delivery_no, access_code } = created.data;
102
+
103
+ const opened = await sl.api.openPartnerLockerDelivery({ body: { location_id: 101, access_code } });
104
+ const { claim_token, attempt } = opened.data;
105
+ if (attempt.can_confirm) {
106
+ await sl.api.confirmPartnerLockerDelivery({ body: { claim_token, decision: 'confirm_all' } });
107
+ }
108
+ const task = await sl.api.partnerLockerDelivery({ deliveryNo: delivery_no });
109
+ ```
110
+
111
+ The same in Python:
112
+
113
+ ```python
114
+ from slocker import SLockerClient, SLockerError
115
+
116
+ sl = SLockerClient(provider_id=4611)
117
+ sl.login('api-partner@example.com', password)
118
+ created = sl.create_partner_locker_delivery(
119
+ idempotency_key='FOOD-20260714-1001-v1',
120
+ body={'external_order_id': 'FOOD-20260714-1001', 'platform_scope_group_id': 12,
121
+ 'recipient': {'phone': '+14165550123'}, 'package_count': 2})
122
+ try:
123
+ opened = sl.open_partner_locker_delivery(body={'location_id': 101, 'access_code': created['data']['access_code']})
124
+ sl.confirm_partner_locker_delivery(body={'claim_token': opened['data']['claim_token'], 'decision': 'confirm_all'})
125
+ except SLockerError as e:
126
+ if e.code == 'LOCKER_DELIVERY_IN_USE':
127
+ ... # another attempt is in progress: wait, or use its claim token
128
+ raise
129
+ ```
130
+
131
+ ## The sandbox: a mock cabinet
132
+
133
+ A mock cabinet runs the same pipeline as a real one without hardware; a task created with `mock: true` only uses mock
134
+ cabinets, and its webhooks carry `livemode: false`, an `event_id` starting `PLE-MOCK-` and the header `X-Webhook-Test:
135
+ 1` (sent to your sandbox URL when one is set). Up to 5 mock cabinets per provider, 1–60 compartments each.
136
+
137
+ ```python
138
+ locker = sl.create_partner_locker_mock_locker(body={'location_id': 101, 'name': 'Dev cabinet', 'compartments': 8})
139
+ device_id = locker['data']['locker']['device_id']
140
+
141
+ task = sl.create_partner_locker_delivery(idempotency_key='TEST-1', body={
142
+ 'external_order_id': 'TEST-1', 'platform_scope_group_id': 12, 'recipient': {'email': 'qa@example.com'}, 'mock': True})
143
+ opened = sl.open_partner_locker_delivery(body={'location_id': 101, 'access_code': task['data']['access_code']})
144
+ sl.confirm_partner_locker_delivery(body={'claim_token': opened['data']['claim_token'], 'decision': 'confirm_all'})
145
+ # → webhook partner_locker.delivery.delivered, livemode false
146
+
147
+ sl.mark_partner_locker_mock_parcels_taken(device_id, body={'compartments': [1]})
148
+ # → webhook partner_locker.pickup.completed (a full pickup settlement)
149
+
150
+ sl.clear_partner_locker_mock_parcels(device_id) # empties the cabinet without a pickup settlement
151
+ sl.set_partner_locker_mock_door(device_id, body={'compartment': 2, 'action': 'open'}) # a door left open
152
+ sl.set_partner_locker_mock_compartments(device_id, body={'compartments': [1, 2], 'grid_length_cm': 20,
153
+ 'grid_width_cm': 20, 'grid_height_cm': 20, 'weight_limit_kg': 5}) # rehearse "does not fit"
154
+ sl.delete_partner_locker_mock_locker(device_id)
155
+ ```
156
+
157
+ Use the location list in the same mode as the task (`mock: true` on `partnerLockerScopeLocations` /
158
+ `partnerLockerNearbyLocations`): a live location for a mock task answers `409 LOCKER_NO_CABINET_FOR_MODE`.
159
+
160
+ ## Webhooks
161
+
162
+ Verify every webhook against the **raw body** exactly as received, before parsing it. sLocker checks the timestamp
163
+ (±5 minutes by default), the `X-Webhook-Content-Digest` and the `X-Webhook-Signature`
164
+ (`v1=` base64 HMAC-SHA256 of `{timestamp}\n{X-Webhook-Delivery-Id}\n{body}` with your endpoint's signing secret), and
165
+ answers the event. During a key rotation the previous secret stays valid for 24 hours: pass both. An endpoint switched
166
+ to Standard Webhooks (`webhook-id` / `webhook-timestamp` / `webhook-signature`) or to CloudEvents structured bodies is
167
+ verified too. The same verification covers the carrier channel's `carrier_package.distribution.*` events.
168
+
169
+ Deduplicate on `eventId` (stable across retries; `deliveryId` changes on every attempt), answer 2xx within 10 seconds,
170
+ and keep sandbox events (`event.test`) apart from production.
171
+
172
+ ```js
173
+ import { verifyWebhook, WebhookVerificationError } from '@superrouteca/slocker';
174
+
175
+ app.post('/webhooks/slocker', express.raw({ type: '*/*' }), async (req, res) => {
176
+ try {
177
+ const event = await verifyWebhook(req.body, req.headers, [process.env.SLOCKER_SECRET, process.env.SLOCKER_OLD_SECRET]);
178
+ if (!event.test) await queue.add(event.eventId, event);
179
+ res.sendStatus(204);
180
+ } catch (e) {
181
+ if (e instanceof WebhookVerificationError) return res.status(400).send(e.reason);
182
+ throw e;
183
+ }
184
+ });
185
+ ```
186
+
187
+ | Language | Verify | Sign a test request |
188
+ |---|---|---|
189
+ | Python | `slocker.verify(body, headers, secrets)` | `slocker.sign(secret, body)` |
190
+ | PHP | `Webhooks::verify(file_get_contents('php://input'), getallheaders(), $secrets)` | `Webhooks::sign($secret, $body)` |
191
+ | Java | `Webhooks.verify(body, headers, secrets)` | `Webhooks.sign(...)` |
192
+ | Go | `slocker.Verify(body, slocker.HeaderMap(r.Header), secrets, nil)` | `slocker.Sign(secret, body, slocker.SignOptions{})` |
193
+ | C# | `Webhooks.Verify(body, headers, secrets)` | `Webhooks.Sign(secret, body)` |
194
+ | JavaScript | `await verifyWebhook(body, headers, secrets)` | `await signWebhook(secret, body)` |
195
+ | Swift | `try Webhooks.verify(body:headers:secrets:)` | `Webhooks.sign(secret:body:)` |
196
+ | Dart | `verifyWebhook(bodyBytes, headers, secrets)` | `signWebhook(secret, bodyBytes)` |
197
+
198
+ A refused webhook raises an error whose reason is one of `MISSING_HEADER`, `INVALID_TIMESTAMP`,
199
+ `TIMESTAMP_OUT_OF_TOLERANCE`, `DIGEST_MISMATCH`, `SIGNATURE_MISMATCH` or `INVALID_PAYLOAD`.
200
+
201
+ ## Errors and retries
202
+
203
+ Every failed call raises one error type with the HTTP `status`, the server's stable `code`, its `message`, the field
204
+ `errors` and `retryAfter` (seconds). Act on the status and the code, never on the message. Codes the SDK adds:
205
+ `UNAUTHENTICATED` (401 without a code: sign in again), `HTTP_<status>` (an answer without a code), `NETWORK_ERROR`
206
+ (no answer; status 0) and `INVALID_ARGUMENT` (a missing path parameter or an unknown operation; nothing was sent).
207
+
208
+ Only repeatable calls are retried: reads, deletes, and the two creates that carry an idempotency key
209
+ (`createPartnerLockerDelivery`, `submitCarrierPackages`). They are repeated after a timeout, a lost connection, 408,
210
+ 425, 429 or 5xx — at most `maxRetries` times (2 by default), waiting what `Retry-After` asks (at most 60 seconds) or
211
+ an exponential backoff with jitter. Opening doors, confirming, aborting, reassigning and every other change are never
212
+ repeated by the SDK: after a timeout, read the task (`partnerLockerDelivery`) before deciding.
213
+
214
+ When the SDK makes the idempotency key itself it is kept across its own retries only; give your own key (one per
215
+ order, unchanged on every retry of yours) so that a retry after a crash finds the original task.
216
+
217
+ ## Each language
218
+
219
+ ### Python
220
+
221
+ ```python
222
+ from slocker import SLockerClient
223
+ sl = SLockerClient(token, provider_id=4611) # sync; urllib from the standard library
224
+ sl.partner_locker_deliveries(status='delivered', per_page=50)
225
+ sl.call('partnerLockerDeliveries', {'status': 'delivered'}) # any operation by name
226
+ ```
227
+
228
+ ### PHP
229
+
230
+ ```php
231
+ use Superroute\SLocker\Client;
232
+ $sl = new Client($token, providerId: 4611); // curl
233
+ $sl->partnerLockerDeliveries(status: 'delivered', perPage: 50);
234
+ ```
235
+
236
+ ### Java and Kotlin
237
+
238
+ Add the Maven repository `https://goodneo-solutions.github.io/slocker-releases/maven`, then
239
+ `implementation("ca.superroute:slocker:0.1.0")` — or, in an Android app, `ca.superroute:slocker-android:0.1.0`.
240
+
241
+ ```java
242
+ Client sl = Client.builder().token(token).providerId(4611L).build(); // java.net.http, or HttpURLConnection on Android
243
+ Object page = sl.partnerLockerDeliveries(Params.create().put("status", "delivered").put("per_page", 50));
244
+ ```
245
+
246
+ ```kotlin
247
+ val sl = SLockerAsync(Client.builder().token(token).providerId(4611L).transport(OkHttpTransport()).build())
248
+ val page = sl.api.partnerLockerDeliveries(status = "delivered", perPage = 50) // suspend, on Dispatchers.IO
249
+ ```
250
+
251
+ Answers are plain JSON values (`Map`, `List`, `String`, `Long`, `Double`, `Boolean`, `null`).
252
+
253
+ ### Go
254
+
255
+ ```go
256
+ sl := slocker.New(slocker.Config{Token: token, ProviderID: 4611})
257
+ page, err := sl.PartnerLockerDeliveries(ctx, slocker.Params{"status": "delivered", "per_page": 50})
258
+ var e *slocker.Error
259
+ if errors.As(err, &e) && e.Code == "LOCKER_RATE_LIMITED" { /* … */ }
260
+ ```
261
+
262
+ ### C#
263
+
264
+ ```csharp
265
+ var sl = new SLockerClient(new SLockerOptions { Token = token, ProviderId = 4611 });
266
+ var page = await sl.PartnerLockerDeliveriesAsync(status: "delivered", perPage: 50);
267
+ ```
268
+
269
+ ### Swift
270
+
271
+ ```swift
272
+ let sl = SLockerClient(token: token, providerId: 4611)
273
+ let page = try await sl.partnerLockerDeliveries(status: "delivered", perPage: 50)
274
+ ```
275
+
276
+ ### Dart and Flutter
277
+
278
+ ```dart
279
+ final sl = SLockerClient(token: token, providerId: 4611);
280
+ final page = await sl.partnerLockerDeliveries(status: 'delivered', perPage: 50);
281
+ ```
282
+
283
+ Every client takes its own transport (for tests, proxies or an HTTP stack the app already has), a base URL (white-label
284
+ API hosts and test environments), the retry count and the wait function.
285
+
286
+ ## Development
287
+
288
+ ```
289
+ specs/ the partner locker slice of Superroute's REST and GraphQL documents, and operations.json
290
+ tools/sync_specs.py copies the slice from a Superroute checkout (and checks no route lacks its Swagger entry)
291
+ tools/generate.py writes every language's operation table and typed methods from the spec
292
+ tools/make_client_cases.py writes tests/cases/client.json: requests, answers, retries, sign-in
293
+ tools/make_webhook_vectors.py writes tests/cases/webhooks.json, signed exactly as Superroute signs
294
+ tests/cases/ the cases every language runs
295
+ ```
296
+
297
+ ```sh
298
+ python3 -I tools/sync_specs.py ../Superroute # then commit specs/ and the generated files
299
+ cd python && python3 -m unittest discover -s tests
300
+ cd js && npm test
301
+ cd php && php tests/run.php
302
+ cd go && go test ./...
303
+ cd java && ./gradlew :slocker:test :slocker-android:test
304
+ dotnet test dotnet/tests/Superroute.SLocker.Tests
305
+ cd swift && swift test
306
+ cd dart && dart pub get && dart test
307
+ ```
308
+
309
+ Never edit generated files (`generated/`, `Generated/`, `_operations.py`, `operations_gen.go`, `SLockerOperations.kt`):
310
+ change Superroute's spec, sync, and generate again.
311
+
312
+ ## Releases
313
+
314
+ sLocker started at 0.1.0; each release adds 0.0.1 to the last one, skipping every version that contains the digit 3
315
+ or 4 (0.1.0, 0.1.1, 0.1.2, 0.1.5, 0.1.6, …, 0.1.9, 0.2.0, …). Tagging a version (`0.1.1`, no `v`) runs
316
+ `.github/workflows/release.yml`, which tests every language and then publishes:
317
+
318
+ - npm (`@superrouteca/slocker`), PyPI (`superroute-slocker`), NuGet (`Superroute.SLocker`) and pub.dev (`slocker`)
319
+ directly, by trusted publishing;
320
+ - to the public repository `Goodneo-Solutions/slocker-releases`: the Swift package (a prebuilt XCFramework attached to
321
+ that repository's GitHub Release of the version), the Maven repository (`maven/`, served by GitHub Pages) with
322
+ `ca.superroute:slocker` and `slocker-android`, and the Go module (`go/`, tag `go/v<version>`);
323
+ - to the public repository `Goodneo-Solutions/slocker-php`: the PHP package at its root, tag `v<version>`, which
324
+ Packagist reads.
325
+
326
+ This source repository stays private; the packages are public under the Superroute SDK License (see [LICENSE](LICENSE)).
@@ -0,0 +1,310 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Goodneo-Solutions/slocker-releases/main/assets/slocker-logo-dark.svg">
3
+ <img src="https://raw.githubusercontent.com/Goodneo-Solutions/slocker-releases/main/assets/slocker-logo.svg" alt="sLocker" height="56">
4
+ </picture>
5
+
6
+ # sLocker
7
+
8
+ Deliver to Superroute smart lockers. sLocker is the SDK for carriers and delivery partners that put parcels into
9
+ Superroute smart lockers: create a delivery task, open the doors remotely for the driver, confirm the deposit, follow
10
+ the pickup, and receive and verify the webhooks — with a mock cabinet to rehearse all of it.
11
+ Superroute SDK License — see [LICENSE](LICENSE) and [open.superroute.ca/developer/license](https://open.superroute.ca/developer/license).
12
+
13
+ | Package | Platform | Install |
14
+ |---|---|---|
15
+ | `superroute-slocker` (PyPI) | Python 3.9+ | `pip install superroute-slocker` |
16
+ | `superroute/slocker` (Packagist) | PHP 8.1+ | `composer require superroute/slocker` |
17
+ | `ca.superroute:slocker` (Maven) | Java 11+ and Android | see [Java and Kotlin](#java-and-kotlin) |
18
+ | `ca.superroute:slocker-android` (Maven) | Kotlin coroutines and OkHttp, for Android | see [Java and Kotlin](#java-and-kotlin) |
19
+ | `github.com/Goodneo-Solutions/slocker-releases/go` | Go 1.21+ | `go get github.com/Goodneo-Solutions/slocker-releases/go` |
20
+ | `Superroute.SLocker` (NuGet) | .NET 8 | `dotnet add package Superroute.SLocker` |
21
+ | `@superrouteca/slocker` (npm) | Node 20+, browsers, Deno, Bun; TypeScript types | `npm install @superrouteca/slocker` |
22
+ | `SLocker` (Swift package) | iOS 15+, macOS 12+ | `https://github.com/Goodneo-Solutions/slocker-releases` |
23
+ | `slocker` (pub.dev) | Dart 3.5+ and Flutter | `dart pub add slocker` |
24
+
25
+ Every package of a release carries the same version and covers the same operations.
26
+
27
+ ## What it covers
28
+
29
+ The partner locker delivery API of Superroute (`/api/v1/third-party-delivery/…`), 40 operations, each a method:
30
+
31
+ - **Delivery tasks** — create (idempotent), list, read, cancel, rotate a code.
32
+ - **At the cabinet** — open the doors remotely with the delivery code, confirm (all or a partial delivery), abort,
33
+ reassign a package to another compartment, cancel the placement, reopen within the 60-second correction window,
34
+ open for pickup.
35
+ - **Scopes and locations** — the grants and sub-scopes, create / update / disable a sub-scope, the locations of a
36
+ scope, the locations near an address or a point.
37
+ - **Usage** — summary, daily, by location.
38
+ - **Carriers** — the carrier profile, intake locations and receiving warehouses, pre-advising packages into a
39
+ warehouse, the carrier's packages, cancelling, rerouting, and resetting a pickup code.
40
+ - **Mock cabinet** — create, list and delete mock lockers, force doors open or closed, take a cabinet offline, change
41
+ its features and compartment sizes, seed parcels, clear them, or declare them taken.
42
+
43
+ The operations are generated from Superroute's published OpenAPI document (`specs/partner-locker-api.json`). The same
44
+ operations exist in Superroute's GraphQL API (`specs/partner-locker-graphql.json` lists them); sLocker calls REST.
45
+
46
+ ## Signing in and choosing the provider
47
+
48
+ The API takes the bearer token of a Superroute API account (`POST /api/v1/user/login`; a token lasts a week). Pass a
49
+ token you already have, or sign in with the client, which keeps the token for the following calls:
50
+
51
+ ```js
52
+ import { SLockerClient } from '@superrouteca/slocker';
53
+
54
+ const sl = new SLockerClient({ providerId: 4611 }); // baseUrl defaults to https://api.superroute.ca
55
+ await sl.login({ email: 'api-partner@example.com', password: process.env.SR_PASSWORD });
56
+ // later: await sl.refreshToken(); await sl.rotateToken({ gracePeriodMinutes: 60 }); await sl.logout();
57
+ ```
58
+
59
+ `providerId` is your third-party delivery provider (sent as `X-Third-Party-Provider-Id`). It may be left out when the
60
+ account acts for exactly one provider; any call can name another one (`providerId` in its parameters).
61
+
62
+ ## A delivery, end to end
63
+
64
+ 1. **Create the task** with the order's own idempotency key. The answer carries `delivery_no`, the driver's
65
+ `access_code` and the recipient's `pickup_code`. A repeat with the same key and the same content answers the
66
+ original task (`replayed: true`); the same key with different content is `409 IDEMPOTENCY_CONFLICT`.
67
+ 2. **At the cabinet**, the driver types the code on the screen — or your app opens the doors remotely with
68
+ `openPartnerLockerDelivery`, which answers a `claim_token` and the compartments that opened.
69
+ 3. **Confirm** with the claim token (`confirm_all`, or `accept_partial` when some packages did not fit). For a remote
70
+ open the cabinet settles the deposit by itself once every opened door reports closed; an explicit confirm afterwards
71
+ answers the same result.
72
+ 4. **Webhooks** tell you the rest: `partner_locker.delivery.doors_opened`, `.delivered` (with the pickup code and the
73
+ compartments), `partner_locker.pickup.completed`, `.failed`, `.expired`, `.cancelled`, `.correction_reopened`.
74
+
75
+ ```js
76
+ const created = await sl.api.createPartnerLockerDelivery({
77
+ idempotencyKey: 'FOOD-20260714-1001-v1', // your own, stable for this order
78
+ body: {
79
+ external_order_id: 'FOOD-20260714-1001',
80
+ platform_scope_group_id: 12,
81
+ recipient: { phone: '+14165550123' },
82
+ package_count: 2,
83
+ },
84
+ });
85
+ const { delivery_no, access_code } = created.data;
86
+
87
+ const opened = await sl.api.openPartnerLockerDelivery({ body: { location_id: 101, access_code } });
88
+ const { claim_token, attempt } = opened.data;
89
+ if (attempt.can_confirm) {
90
+ await sl.api.confirmPartnerLockerDelivery({ body: { claim_token, decision: 'confirm_all' } });
91
+ }
92
+ const task = await sl.api.partnerLockerDelivery({ deliveryNo: delivery_no });
93
+ ```
94
+
95
+ The same in Python:
96
+
97
+ ```python
98
+ from slocker import SLockerClient, SLockerError
99
+
100
+ sl = SLockerClient(provider_id=4611)
101
+ sl.login('api-partner@example.com', password)
102
+ created = sl.create_partner_locker_delivery(
103
+ idempotency_key='FOOD-20260714-1001-v1',
104
+ body={'external_order_id': 'FOOD-20260714-1001', 'platform_scope_group_id': 12,
105
+ 'recipient': {'phone': '+14165550123'}, 'package_count': 2})
106
+ try:
107
+ opened = sl.open_partner_locker_delivery(body={'location_id': 101, 'access_code': created['data']['access_code']})
108
+ sl.confirm_partner_locker_delivery(body={'claim_token': opened['data']['claim_token'], 'decision': 'confirm_all'})
109
+ except SLockerError as e:
110
+ if e.code == 'LOCKER_DELIVERY_IN_USE':
111
+ ... # another attempt is in progress: wait, or use its claim token
112
+ raise
113
+ ```
114
+
115
+ ## The sandbox: a mock cabinet
116
+
117
+ A mock cabinet runs the same pipeline as a real one without hardware; a task created with `mock: true` only uses mock
118
+ cabinets, and its webhooks carry `livemode: false`, an `event_id` starting `PLE-MOCK-` and the header `X-Webhook-Test:
119
+ 1` (sent to your sandbox URL when one is set). Up to 5 mock cabinets per provider, 1–60 compartments each.
120
+
121
+ ```python
122
+ locker = sl.create_partner_locker_mock_locker(body={'location_id': 101, 'name': 'Dev cabinet', 'compartments': 8})
123
+ device_id = locker['data']['locker']['device_id']
124
+
125
+ task = sl.create_partner_locker_delivery(idempotency_key='TEST-1', body={
126
+ 'external_order_id': 'TEST-1', 'platform_scope_group_id': 12, 'recipient': {'email': 'qa@example.com'}, 'mock': True})
127
+ opened = sl.open_partner_locker_delivery(body={'location_id': 101, 'access_code': task['data']['access_code']})
128
+ sl.confirm_partner_locker_delivery(body={'claim_token': opened['data']['claim_token'], 'decision': 'confirm_all'})
129
+ # → webhook partner_locker.delivery.delivered, livemode false
130
+
131
+ sl.mark_partner_locker_mock_parcels_taken(device_id, body={'compartments': [1]})
132
+ # → webhook partner_locker.pickup.completed (a full pickup settlement)
133
+
134
+ sl.clear_partner_locker_mock_parcels(device_id) # empties the cabinet without a pickup settlement
135
+ sl.set_partner_locker_mock_door(device_id, body={'compartment': 2, 'action': 'open'}) # a door left open
136
+ sl.set_partner_locker_mock_compartments(device_id, body={'compartments': [1, 2], 'grid_length_cm': 20,
137
+ 'grid_width_cm': 20, 'grid_height_cm': 20, 'weight_limit_kg': 5}) # rehearse "does not fit"
138
+ sl.delete_partner_locker_mock_locker(device_id)
139
+ ```
140
+
141
+ Use the location list in the same mode as the task (`mock: true` on `partnerLockerScopeLocations` /
142
+ `partnerLockerNearbyLocations`): a live location for a mock task answers `409 LOCKER_NO_CABINET_FOR_MODE`.
143
+
144
+ ## Webhooks
145
+
146
+ Verify every webhook against the **raw body** exactly as received, before parsing it. sLocker checks the timestamp
147
+ (±5 minutes by default), the `X-Webhook-Content-Digest` and the `X-Webhook-Signature`
148
+ (`v1=` base64 HMAC-SHA256 of `{timestamp}\n{X-Webhook-Delivery-Id}\n{body}` with your endpoint's signing secret), and
149
+ answers the event. During a key rotation the previous secret stays valid for 24 hours: pass both. An endpoint switched
150
+ to Standard Webhooks (`webhook-id` / `webhook-timestamp` / `webhook-signature`) or to CloudEvents structured bodies is
151
+ verified too. The same verification covers the carrier channel's `carrier_package.distribution.*` events.
152
+
153
+ Deduplicate on `eventId` (stable across retries; `deliveryId` changes on every attempt), answer 2xx within 10 seconds,
154
+ and keep sandbox events (`event.test`) apart from production.
155
+
156
+ ```js
157
+ import { verifyWebhook, WebhookVerificationError } from '@superrouteca/slocker';
158
+
159
+ app.post('/webhooks/slocker', express.raw({ type: '*/*' }), async (req, res) => {
160
+ try {
161
+ const event = await verifyWebhook(req.body, req.headers, [process.env.SLOCKER_SECRET, process.env.SLOCKER_OLD_SECRET]);
162
+ if (!event.test) await queue.add(event.eventId, event);
163
+ res.sendStatus(204);
164
+ } catch (e) {
165
+ if (e instanceof WebhookVerificationError) return res.status(400).send(e.reason);
166
+ throw e;
167
+ }
168
+ });
169
+ ```
170
+
171
+ | Language | Verify | Sign a test request |
172
+ |---|---|---|
173
+ | Python | `slocker.verify(body, headers, secrets)` | `slocker.sign(secret, body)` |
174
+ | PHP | `Webhooks::verify(file_get_contents('php://input'), getallheaders(), $secrets)` | `Webhooks::sign($secret, $body)` |
175
+ | Java | `Webhooks.verify(body, headers, secrets)` | `Webhooks.sign(...)` |
176
+ | Go | `slocker.Verify(body, slocker.HeaderMap(r.Header), secrets, nil)` | `slocker.Sign(secret, body, slocker.SignOptions{})` |
177
+ | C# | `Webhooks.Verify(body, headers, secrets)` | `Webhooks.Sign(secret, body)` |
178
+ | JavaScript | `await verifyWebhook(body, headers, secrets)` | `await signWebhook(secret, body)` |
179
+ | Swift | `try Webhooks.verify(body:headers:secrets:)` | `Webhooks.sign(secret:body:)` |
180
+ | Dart | `verifyWebhook(bodyBytes, headers, secrets)` | `signWebhook(secret, bodyBytes)` |
181
+
182
+ A refused webhook raises an error whose reason is one of `MISSING_HEADER`, `INVALID_TIMESTAMP`,
183
+ `TIMESTAMP_OUT_OF_TOLERANCE`, `DIGEST_MISMATCH`, `SIGNATURE_MISMATCH` or `INVALID_PAYLOAD`.
184
+
185
+ ## Errors and retries
186
+
187
+ Every failed call raises one error type with the HTTP `status`, the server's stable `code`, its `message`, the field
188
+ `errors` and `retryAfter` (seconds). Act on the status and the code, never on the message. Codes the SDK adds:
189
+ `UNAUTHENTICATED` (401 without a code: sign in again), `HTTP_<status>` (an answer without a code), `NETWORK_ERROR`
190
+ (no answer; status 0) and `INVALID_ARGUMENT` (a missing path parameter or an unknown operation; nothing was sent).
191
+
192
+ Only repeatable calls are retried: reads, deletes, and the two creates that carry an idempotency key
193
+ (`createPartnerLockerDelivery`, `submitCarrierPackages`). They are repeated after a timeout, a lost connection, 408,
194
+ 425, 429 or 5xx — at most `maxRetries` times (2 by default), waiting what `Retry-After` asks (at most 60 seconds) or
195
+ an exponential backoff with jitter. Opening doors, confirming, aborting, reassigning and every other change are never
196
+ repeated by the SDK: after a timeout, read the task (`partnerLockerDelivery`) before deciding.
197
+
198
+ When the SDK makes the idempotency key itself it is kept across its own retries only; give your own key (one per
199
+ order, unchanged on every retry of yours) so that a retry after a crash finds the original task.
200
+
201
+ ## Each language
202
+
203
+ ### Python
204
+
205
+ ```python
206
+ from slocker import SLockerClient
207
+ sl = SLockerClient(token, provider_id=4611) # sync; urllib from the standard library
208
+ sl.partner_locker_deliveries(status='delivered', per_page=50)
209
+ sl.call('partnerLockerDeliveries', {'status': 'delivered'}) # any operation by name
210
+ ```
211
+
212
+ ### PHP
213
+
214
+ ```php
215
+ use Superroute\SLocker\Client;
216
+ $sl = new Client($token, providerId: 4611); // curl
217
+ $sl->partnerLockerDeliveries(status: 'delivered', perPage: 50);
218
+ ```
219
+
220
+ ### Java and Kotlin
221
+
222
+ Add the Maven repository `https://goodneo-solutions.github.io/slocker-releases/maven`, then
223
+ `implementation("ca.superroute:slocker:0.1.0")` — or, in an Android app, `ca.superroute:slocker-android:0.1.0`.
224
+
225
+ ```java
226
+ Client sl = Client.builder().token(token).providerId(4611L).build(); // java.net.http, or HttpURLConnection on Android
227
+ Object page = sl.partnerLockerDeliveries(Params.create().put("status", "delivered").put("per_page", 50));
228
+ ```
229
+
230
+ ```kotlin
231
+ val sl = SLockerAsync(Client.builder().token(token).providerId(4611L).transport(OkHttpTransport()).build())
232
+ val page = sl.api.partnerLockerDeliveries(status = "delivered", perPage = 50) // suspend, on Dispatchers.IO
233
+ ```
234
+
235
+ Answers are plain JSON values (`Map`, `List`, `String`, `Long`, `Double`, `Boolean`, `null`).
236
+
237
+ ### Go
238
+
239
+ ```go
240
+ sl := slocker.New(slocker.Config{Token: token, ProviderID: 4611})
241
+ page, err := sl.PartnerLockerDeliveries(ctx, slocker.Params{"status": "delivered", "per_page": 50})
242
+ var e *slocker.Error
243
+ if errors.As(err, &e) && e.Code == "LOCKER_RATE_LIMITED" { /* … */ }
244
+ ```
245
+
246
+ ### C#
247
+
248
+ ```csharp
249
+ var sl = new SLockerClient(new SLockerOptions { Token = token, ProviderId = 4611 });
250
+ var page = await sl.PartnerLockerDeliveriesAsync(status: "delivered", perPage: 50);
251
+ ```
252
+
253
+ ### Swift
254
+
255
+ ```swift
256
+ let sl = SLockerClient(token: token, providerId: 4611)
257
+ let page = try await sl.partnerLockerDeliveries(status: "delivered", perPage: 50)
258
+ ```
259
+
260
+ ### Dart and Flutter
261
+
262
+ ```dart
263
+ final sl = SLockerClient(token: token, providerId: 4611);
264
+ final page = await sl.partnerLockerDeliveries(status: 'delivered', perPage: 50);
265
+ ```
266
+
267
+ Every client takes its own transport (for tests, proxies or an HTTP stack the app already has), a base URL (white-label
268
+ API hosts and test environments), the retry count and the wait function.
269
+
270
+ ## Development
271
+
272
+ ```
273
+ specs/ the partner locker slice of Superroute's REST and GraphQL documents, and operations.json
274
+ tools/sync_specs.py copies the slice from a Superroute checkout (and checks no route lacks its Swagger entry)
275
+ tools/generate.py writes every language's operation table and typed methods from the spec
276
+ tools/make_client_cases.py writes tests/cases/client.json: requests, answers, retries, sign-in
277
+ tools/make_webhook_vectors.py writes tests/cases/webhooks.json, signed exactly as Superroute signs
278
+ tests/cases/ the cases every language runs
279
+ ```
280
+
281
+ ```sh
282
+ python3 -I tools/sync_specs.py ../Superroute # then commit specs/ and the generated files
283
+ cd python && python3 -m unittest discover -s tests
284
+ cd js && npm test
285
+ cd php && php tests/run.php
286
+ cd go && go test ./...
287
+ cd java && ./gradlew :slocker:test :slocker-android:test
288
+ dotnet test dotnet/tests/Superroute.SLocker.Tests
289
+ cd swift && swift test
290
+ cd dart && dart pub get && dart test
291
+ ```
292
+
293
+ Never edit generated files (`generated/`, `Generated/`, `_operations.py`, `operations_gen.go`, `SLockerOperations.kt`):
294
+ change Superroute's spec, sync, and generate again.
295
+
296
+ ## Releases
297
+
298
+ sLocker started at 0.1.0; each release adds 0.0.1 to the last one, skipping every version that contains the digit 3
299
+ or 4 (0.1.0, 0.1.1, 0.1.2, 0.1.5, 0.1.6, …, 0.1.9, 0.2.0, …). Tagging a version (`0.1.1`, no `v`) runs
300
+ `.github/workflows/release.yml`, which tests every language and then publishes:
301
+
302
+ - npm (`@superrouteca/slocker`), PyPI (`superroute-slocker`), NuGet (`Superroute.SLocker`) and pub.dev (`slocker`)
303
+ directly, by trusted publishing;
304
+ - to the public repository `Goodneo-Solutions/slocker-releases`: the Swift package (a prebuilt XCFramework attached to
305
+ that repository's GitHub Release of the version), the Maven repository (`maven/`, served by GitHub Pages) with
306
+ `ca.superroute:slocker` and `slocker-android`, and the Go module (`go/`, tag `go/v<version>`);
307
+ - to the public repository `Goodneo-Solutions/slocker-php`: the PHP package at its root, tag `v<version>`, which
308
+ Packagist reads.
309
+
310
+ This source repository stays private; the packages are public under the Superroute SDK License (see [LICENSE](LICENSE)).