wishop 1.2.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.
- wishop-1.2.0/.github/workflows/test.yml +21 -0
- wishop-1.2.0/.gitignore +7 -0
- wishop-1.2.0/CHANGELOG.md +40 -0
- wishop-1.2.0/LICENSE +21 -0
- wishop-1.2.0/PKG-INFO +340 -0
- wishop-1.2.0/README.md +296 -0
- wishop-1.2.0/pyproject.toml +39 -0
- wishop-1.2.0/tests/test_client.py +339 -0
- wishop-1.2.0/tests/test_payouts.py +166 -0
- wishop-1.2.0/tests/test_webhooks.py +129 -0
- wishop-1.2.0/wishop/__init__.py +123 -0
- wishop-1.2.0/wishop/_parsing.py +120 -0
- wishop-1.2.0/wishop/_transport.py +73 -0
- wishop-1.2.0/wishop/client.py +34 -0
- wishop-1.2.0/wishop/errors.py +79 -0
- wishop-1.2.0/wishop/py.typed +0 -0
- wishop-1.2.0/wishop/resources/__init__.py +0 -0
- wishop-1.2.0/wishop/resources/charges.py +81 -0
- wishop-1.2.0/wishop/resources/payouts.py +92 -0
- wishop-1.2.0/wishop/resources/refunds.py +46 -0
- wishop-1.2.0/wishop/types.py +154 -0
- wishop-1.2.0/wishop/webhooks.py +69 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: mypy
|
|
21
|
+
- run: pytest
|
wishop-1.2.0/.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
- `Charge.channel` (`"api"` or `"app"`). Charges started by a customer in the
|
|
6
|
+
WiShop app are returned by the API and carried by webhooks; the field was
|
|
7
|
+
previously dropped.
|
|
8
|
+
- `Charge.metadata` and `charges.create(metadata=...)` are typed `Dict[str, Any]`:
|
|
9
|
+
the API accepts any JSON value, not only strings.
|
|
10
|
+
|
|
11
|
+
## 1.1.0
|
|
12
|
+
|
|
13
|
+
Payouts — send money from your wallet to a customer's.
|
|
14
|
+
|
|
15
|
+
- `payouts.create`, `payouts.retrieve`, `payouts.list`
|
|
16
|
+
- `Payout` and `PayoutList` dataclasses
|
|
17
|
+
- `PAYOUT_STATUS_*` constants, plus
|
|
18
|
+
`FAIL_REASON_INSUFFICIENT_MERCHANT_BALANCE` and
|
|
19
|
+
`FAIL_REASON_RECIPIENT_INELIGIBLE`
|
|
20
|
+
- `EVENT_PAYOUT_CREATED`, `EVENT_PAYOUT_SUCCEEDED`, `EVENT_PAYOUT_FAILED`
|
|
21
|
+
|
|
22
|
+
Fixed: `construct_event` raised `KeyError` on every `payout.*` event, because
|
|
23
|
+
`parse_event` read `data["charge"]` unconditionally. It now parses only the
|
|
24
|
+
object the event carries.
|
|
25
|
+
|
|
26
|
+
**Type change:** `EventData.charge` is now `Optional[Charge]` and
|
|
27
|
+
`EventData.payout` was added. Code that reads `event.data.charge` without a
|
|
28
|
+
`None` check needs one under mypy. Charge events are unchanged at runtime.
|
|
29
|
+
|
|
30
|
+
`payouts.create` takes `idempotency_key` as a required keyword argument — the
|
|
31
|
+
API requires one, and an un-keyed payout retry pays twice.
|
|
32
|
+
|
|
33
|
+
## 1.0.0
|
|
34
|
+
|
|
35
|
+
Initial release.
|
|
36
|
+
|
|
37
|
+
- `charges.create`, `charges.retrieve`, `charges.list`
|
|
38
|
+
- `refunds.create`, `refunds.list`
|
|
39
|
+
- `construct_event` for webhook signature verification
|
|
40
|
+
- Typed exceptions for every documented status code
|
wishop-1.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wishop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
wishop-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: wishop
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: The official Python client for the Wishop API
|
|
5
|
+
Project-URL: Documentation, https://developers.wishopht.com
|
|
6
|
+
Project-URL: Source, https://github.com/wishopht/wishop-python
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Wishop
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.9
|
|
38
|
+
Requires-Dist: requests>=2.25
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
42
|
+
Requires-Dist: responses>=0.23; extra == 'dev'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# Wishop Python
|
|
46
|
+
|
|
47
|
+
The official Python client for the [Wishop API](https://developers.wishopht.com).
|
|
48
|
+
|
|
49
|
+
Create charges, send customers to a hosted checkout page, refund them, and verify webhooks.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install wishop
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Python 3.9 or later.
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import os
|
|
63
|
+
|
|
64
|
+
from wishop import Wishop
|
|
65
|
+
|
|
66
|
+
client = Wishop(os.environ["WISHOP_SECRET_KEY"])
|
|
67
|
+
|
|
68
|
+
charge = client.charges.create(
|
|
69
|
+
amount="1500.00",
|
|
70
|
+
currency="HTG",
|
|
71
|
+
order_id="order_1042",
|
|
72
|
+
description="Two large coffees",
|
|
73
|
+
return_url="https://yourstore.com/orders/1042/complete",
|
|
74
|
+
metadata={"cart_id": "c_88213"},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
print(charge.pay_url)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Send the customer to `charge.pay_url`. It is returned on create only, so persist it with the order.
|
|
81
|
+
|
|
82
|
+
When the payment reaches a terminal state, Wishop sends a webhook and returns the customer to your `return_url`. Confirm the outcome from the webhook or by retrieving the charge — never from the redirect alone.
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
client = Wishop(
|
|
88
|
+
os.environ["WISHOP_SECRET_KEY"],
|
|
89
|
+
timeout=10.0,
|
|
90
|
+
session=my_session,
|
|
91
|
+
base_url="http://localhost:8080",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Argument | Default | Notes |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `timeout` | `30.0` | Seconds, applied to every request |
|
|
98
|
+
| `session` | a new `requests.Session` | Supply your own for proxies, retries at the transport layer, or custom TLS |
|
|
99
|
+
| `base_url` | `https://gatewayapi.wishopht.com` | For pointing at a mock server in your own tests |
|
|
100
|
+
|
|
101
|
+
Test and live mode are selected by the key, not by configuration. A `sk_test_` key reaches test data; a `sk_live_` key reaches live data.
|
|
102
|
+
|
|
103
|
+
The client is synchronous. From an async framework, run calls in a worker thread so the event loop is not blocked:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
charge = await asyncio.to_thread(client.charges.create, amount="1500.00", currency="HTG")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Charges
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
charge = client.charges.create(amount="1500.00", currency="HTG")
|
|
113
|
+
charge = client.charges.retrieve("ch_3kQ8xR2mVn7pLdYt")
|
|
114
|
+
page = client.charges.list(status="PAID", limit=100)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`list` returns one page. Pass the id of the last charge as `starting_after` while `has_more` is true:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
def all_charges(client, status=None):
|
|
121
|
+
starting_after = None
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
page = client.charges.list(limit=100, status=status, starting_after=starting_after)
|
|
125
|
+
|
|
126
|
+
for charge in page.charges:
|
|
127
|
+
yield charge
|
|
128
|
+
|
|
129
|
+
if not page.has_more:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
starting_after = page.charges[-1].id
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Search across charge id and order id with `query`, which maps to the API's `q` parameter.
|
|
136
|
+
|
|
137
|
+
## Refunds
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
refund = client.refunds.create(
|
|
141
|
+
"ch_3kQ8xR2mVn7pLdYt",
|
|
142
|
+
amount="500.00",
|
|
143
|
+
reason="requested_by_customer",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
refunds = client.refunds.list("ch_3kQ8xR2mVn7pLdYt")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Omit `amount` to refund everything still refundable. Refund lists are not paginated.
|
|
150
|
+
|
|
151
|
+
## Payouts
|
|
152
|
+
|
|
153
|
+
Sends money from your wallet to a customer's — payroll, marketplace settlements, rewards.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
payout = client.payouts.create(
|
|
157
|
+
amount="150.00",
|
|
158
|
+
currency="HTG",
|
|
159
|
+
recipient_phone="+50931234567",
|
|
160
|
+
reference="June payroll",
|
|
161
|
+
idempotency_key=f"payroll-2026-06-{employee.id}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
payouts = client.payouts.list(status="FAILED")
|
|
165
|
+
one = client.payouts.retrieve("po_7Hn2vB9xQm4tRkLw")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`idempotency_key` has **no default** here, unlike everywhere else in this client: a payout pushes money out, so an un-keyed retry after a dropped connection is indistinguishable from a second payment. Your wallet is debited `amount + fee`.
|
|
169
|
+
|
|
170
|
+
Three outcomes, and the middle one is the one to handle:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
if payout.status == wishop.PAYOUT_STATUS_SUCCEEDED:
|
|
174
|
+
... # the money moved
|
|
175
|
+
elif payout.status == wishop.PAYOUT_STATUS_PENDING:
|
|
176
|
+
# The wallet's answer was lost — the money may or may not have moved.
|
|
177
|
+
# It resolves on its own and a webhook reports the outcome.
|
|
178
|
+
# Never send a replacement; retry with the SAME key if you must.
|
|
179
|
+
...
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A decline raises `UnprocessableError` and still records a `FAILED` payout you can `retrieve` — unlike a rejected refund, which leaves nothing behind.
|
|
183
|
+
|
|
184
|
+
## Money
|
|
185
|
+
|
|
186
|
+
Amounts are strings, never numbers, and every field that holds money is a string: `amount`, `fee`, `net`, `customer_debit_amount`, `amount_refunded`. Parse them with `Decimal`, never a float.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from decimal import Decimal
|
|
190
|
+
|
|
191
|
+
amount = Decimal(charge.amount)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Idempotency
|
|
195
|
+
|
|
196
|
+
Pass a key on charge and refund creation so a retry after a timeout cannot charge twice:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
charge = client.charges.create(
|
|
200
|
+
amount="1500.00",
|
|
201
|
+
currency="HTG",
|
|
202
|
+
idempotency_key=f"charge-{order.id}-{order.payment_attempt}",
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
On charges and refunds nothing is sent unless you pass it. **Payouts are the exception: `idempotency_key` is a required keyword argument**, so omitting it is a `TypeError` rather than a rejected request.
|
|
207
|
+
|
|
208
|
+
Derive a charge key from the payment *attempt*, not the order — reusing an order id replays the previous failure instead of creating the charge a retrying customer needs. Derive a payout key the opposite way, from the payment itself, and reuse it across retries: a fresh key on a payout retry sends a second payment. Keys are remembered for 24 hours.
|
|
209
|
+
|
|
210
|
+
## Errors
|
|
211
|
+
|
|
212
|
+
Every failure raises a typed exception:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from wishop import RateLimitError, UnprocessableError, WishopError
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
charge = client.charges.create(amount="1500.00", currency="HTG")
|
|
219
|
+
except RateLimitError as exc:
|
|
220
|
+
time.sleep(exc.retry_after or 1)
|
|
221
|
+
except UnprocessableError as exc:
|
|
222
|
+
log.warning(exc.message)
|
|
223
|
+
except WishopError as exc:
|
|
224
|
+
log.error("wishop failed with %s", exc.status_code)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Exception | Status | Cause |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `ValidationError` | 400 | Malformed body, invalid amount or currency, metadata over the limits |
|
|
230
|
+
| `AuthenticationError` | 401 | Missing, malformed, unknown, or revoked key |
|
|
231
|
+
| `PermissionError` | 403 | Live key from an address that is not whitelisted |
|
|
232
|
+
| `NotFoundError` | 404 | No charge, refund or payout with that id in this mode; or no wallet for a payout recipient's phone |
|
|
233
|
+
| `ConflictError` | 409 | Idempotency key still in flight, charge no longer payable, or wallet not provisioned for live payouts |
|
|
234
|
+
| `ChargeExpiredError` | 410 | Charge expired |
|
|
235
|
+
| `PayloadTooLargeError` | 413 | Upload too large |
|
|
236
|
+
| `UnprocessableError` | 422 | Currency not enabled, charge not refundable, refund exceeds remaining, wallet declined, or a payout declined for balance or recipient |
|
|
237
|
+
| `RateLimitError` | 429 | Rate limit exceeded; carries `retry_after` in seconds |
|
|
238
|
+
| `APIError` | 5xx and anything unmapped | Failure on the gateway side |
|
|
239
|
+
| `APIConnectionError` | — | The request never completed: DNS, TLS, connection reset, or timeout |
|
|
240
|
+
|
|
241
|
+
All of them subclass `WishopError`, which carries `.status_code` and `.message`.
|
|
242
|
+
|
|
243
|
+
`PermissionError` shadows the builtin of the same name when imported bare — import it as `from wishop import PermissionError as WishopPermissionError` if that matters in your module.
|
|
244
|
+
|
|
245
|
+
`.message` is prose and its wording can change — branch on the exception type or `.status_code`, never on the text.
|
|
246
|
+
|
|
247
|
+
The client does not retry. `ConflictError`, `RateLimitError`, and `APIError` are safe to retry with backoff and the same idempotency key; nothing else is.
|
|
248
|
+
|
|
249
|
+
## Payment failures
|
|
250
|
+
|
|
251
|
+
A failed payment is not an exception — the call succeeded, the payment didn't. Read `status` and `fail_reason`:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
if charge.status == "FAILED":
|
|
255
|
+
show(EXPLANATIONS.get(charge.fail_reason, "The payment could not be completed."))
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
| `fail_reason` | Meaning |
|
|
259
|
+
| --- | --- |
|
|
260
|
+
| `INSUFFICIENT_FUNDS` | Wallet balance did not cover the payment |
|
|
261
|
+
| `PIN_LOCKED` | Wallet locked after repeated incorrect PINs |
|
|
262
|
+
| `OTP_EXPIRED` | One-time code expired before use |
|
|
263
|
+
| `OTP_ATTEMPTS_EXCEEDED` | Too many incorrect one-time codes |
|
|
264
|
+
| `WALLET_NOT_FOUND` | No wallet for that phone number |
|
|
265
|
+
| `DECLINED` | Wallet refused the payment |
|
|
266
|
+
| `TIMEOUT` | Confirmation timed out |
|
|
267
|
+
| `INTERNAL_ERROR` | Failure on our side |
|
|
268
|
+
|
|
269
|
+
Keep a default branch — new reasons can be added, and `fail_reason` is a plain string so an unknown value arrives intact rather than failing to parse.
|
|
270
|
+
|
|
271
|
+
## Webhooks
|
|
272
|
+
|
|
273
|
+
Verification needs the body **exactly as received**. `request.json` and `request.get_json()` will not do — they hand you a parsed object, and re-serializing it changes whitespace and key order so the signature will never match.
|
|
274
|
+
|
|
275
|
+
Flask:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from wishop import SignatureVerificationError, construct_event
|
|
279
|
+
|
|
280
|
+
@app.post("/webhooks/wishop")
|
|
281
|
+
def wishop_webhook():
|
|
282
|
+
try:
|
|
283
|
+
event = construct_event(
|
|
284
|
+
request.get_data(),
|
|
285
|
+
request.headers.get("X-Wishop-Signature", ""),
|
|
286
|
+
os.environ["WISHOP_WEBHOOK_SECRET"],
|
|
287
|
+
)
|
|
288
|
+
except SignatureVerificationError:
|
|
289
|
+
return "", 400
|
|
290
|
+
|
|
291
|
+
if events.seen(request.headers["X-Webhook-Id"]):
|
|
292
|
+
return "", 200
|
|
293
|
+
|
|
294
|
+
if event.type == "charge.paid":
|
|
295
|
+
fulfil(event.data.charge)
|
|
296
|
+
elif event.type == "charge.failed":
|
|
297
|
+
notify_failure(event.data.charge)
|
|
298
|
+
elif event.type == "charge.expired":
|
|
299
|
+
release_stock(event.data.charge)
|
|
300
|
+
elif event.type == "charge.refunded":
|
|
301
|
+
record_refund(event.data.refund)
|
|
302
|
+
elif event.type == "payout.succeeded":
|
|
303
|
+
mark_payout_sent(event.data.payout)
|
|
304
|
+
elif event.type == "payout.failed":
|
|
305
|
+
mark_payout_failed(event.data.payout)
|
|
306
|
+
|
|
307
|
+
return "", 200
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Django uses `request.body`; FastAPI uses `await request.body()`.
|
|
311
|
+
|
|
312
|
+
`construct_event` rejects a stale timestamp, an altered body, and a signature that does not match. It accepts a match on any `v1` value, which is what keeps events flowing during a secret rotation. The default tolerance is 300 seconds:
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
event = construct_event(body, signature, secret, tolerance=60)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Delivery is at-least-once, so deduplicate on the `X-Webhook-Id` header, which is stable across retries. Record it in the same transaction as the work it triggers. Respond `2xx` once the event is durably recorded and queue anything slow — you have about ten seconds per attempt.
|
|
319
|
+
|
|
320
|
+
`event.data.refund` is set only on `charge.refunded`. A partial refund leaves the charge `PAID`; read `amount_refunded`.
|
|
321
|
+
|
|
322
|
+
`event.data.charge` is `None` on `payout.*` events and `event.data.payout` is `None` on `charge.*` ones — `event.data.object` says which you have.
|
|
323
|
+
|
|
324
|
+
## Testing
|
|
325
|
+
|
|
326
|
+
Test mode uses `sk_test_` keys against the same host. The outcome is chosen by a phone number — the one the customer enters for a charge, the recipient's for a payout. The two use different tables; `+50900000006` is how you exercise a pending payout. See [test phone numbers](https://developers.wishopht.com/testing/test-phone-numbers).
|
|
327
|
+
|
|
328
|
+
To test your own code against this client, point it at a local server:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
client = Wishop("sk_test_key", base_url="http://localhost:8080")
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Reference
|
|
335
|
+
|
|
336
|
+
Field-by-field documentation for the charge, refund, payout, and event objects lives at [developers.wishopht.com](https://developers.wishopht.com).
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
MIT
|