shepherd-sdk 0.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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.5
2
+ Name: shepherd-sdk
3
+ Version: 0.2.0
4
+ Summary: Python client for the Shepherd API: connect wallets, classify transactions, and pull financial statements.
5
+ Project-URL: Homepage, https://getshepherd.co/developers.html
6
+ Author-email: Shepherd <alan@getshepherd.co>
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.8
12
+ Requires-Dist: requests>=2.25
13
+ Description-Content-Type: text/markdown
14
+
15
+ # shepherd-sdk (Python)
16
+
17
+ Python client for the [Shepherd API](https://getshepherd.co/developers.html): connect wallets, classify every transaction, and pull audit-ready financial statements.
18
+
19
+ ```bash
20
+ pip install shepherd-sdk
21
+ ```
22
+
23
+ ```python
24
+ from shepherd import Shepherd
25
+
26
+ shep = Shepherd("shep_live_...") # or Shepherd.create_sandbox() for an instant test key
27
+
28
+ wallet = shep.wallets.connect("0x...", "base", end_user_id="cust_4821")
29
+ pnl = shep.statements.income(start_date="2026-09-01", end_date="2026-09-30", end_user_id="cust_4821")
30
+ print(pnl["net_income"])
31
+ ```
32
+
33
+ ## Sandbox
34
+
35
+ ```python
36
+ shep = Shepherd.create_sandbox(email="you@company.com")
37
+ print(shep.sandbox_info) # seeded wallet + customer
38
+ ```
39
+
40
+ Sandbox keys start with `shep_test_`. Data is synthetic; connecting any address returns generated activity.
41
+
42
+ ## Webhooks
43
+
44
+ ```python
45
+ from shepherd import construct_event, SignatureVerificationError
46
+
47
+ # Flask example
48
+ @app.post("/shepherd/webhook")
49
+ def hook():
50
+ try:
51
+ event = construct_event(request.get_data(), request.headers["Shepherd-Signature"], WEBHOOK_SECRET)
52
+ except SignatureVerificationError:
53
+ return "", 400
54
+ if event["type"] == "wallet.scan_completed":
55
+ ...
56
+ return "", 200
57
+ ```
58
+
59
+ ## Push your own transactions
60
+
61
+ ```python
62
+ acct = shep.accounts.create("acct_123", type="bank", currency="USD", end_user_id="cust_4821")
63
+ r = shep.accounts.push_transactions(acct["id"], [
64
+ {"external_id": "tx_1", "date": "2026-09-03", "amount": 1250.00, "direction": "inflow", "counterparty": "Acme Corp"},
65
+ ])
66
+ print(r["created"], r["duplicates"], r["rejected"])
67
+ shep.jobs.wait(r["classification"]["job_id"]) # classification runs in the background
68
+ ```
69
+
70
+ Sending the same `external_id` again changes nothing, so it is safe to retry.
71
+
72
+ ## Pagination
73
+
74
+ ```python
75
+ for tx in shep.transactions.iterate(status="unclassified"): # follows pages for you
76
+ ...
77
+ changed = shep.transactions.iterate(updated_since="2026-10-01T00:00:00Z") # keep a copy in sync
78
+ ```
79
+
80
+ ## One set of books per customer (platform accounts)
81
+
82
+ ```python
83
+ shep.workspaces.create("cust_4821", "Acme Corp")
84
+ acme = shep.with_workspace("cust_4821") # every call on `acme` reads and writes that customer only
85
+ acme.statements.income(start_date="2026-09-01")
86
+ ```
87
+
88
+ ## Keys and safe retries
89
+
90
+ ```python
91
+ key = shep.api_keys.create(label="reporting", scope="read", expires_in_days=90) # admin key needed
92
+ shep.api_keys.rotate(key["id"], grace_hours=24)
93
+ shep.accounts.create("acct_123", idempotency_key="create-acct_123") # POSTs with a key retry safely
94
+ ```
95
+
96
+ ## Errors
97
+
98
+ Every failure raises a `ShepherdError` subclass with `.status`, `.message`, `.code`, `.request_id`, `.body`:
99
+ `InvalidRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `.retry_after`), `ServerError` (5xx).
100
+ GET requests, and POSTs sent with an `idempotency_key`, retry automatically on 429/502/503 and honour `Retry-After`.
101
+
102
+ ## Resources
103
+
104
+ `wallets`, `transactions`, `accounts`, `jobs`, `statements`, `entities`, `opening_balances`, `accruals`, `classification_rules`, `chart_of_accounts`, `periods`, `reconciliation`, `audit_log`, `api_keys`, `workspaces`, `webhooks`, `sandbox`. See the [API reference](https://getshepherd.co/developers-docs.html#reference).
@@ -0,0 +1,90 @@
1
+ # shepherd-sdk (Python)
2
+
3
+ Python client for the [Shepherd API](https://getshepherd.co/developers.html): connect wallets, classify every transaction, and pull audit-ready financial statements.
4
+
5
+ ```bash
6
+ pip install shepherd-sdk
7
+ ```
8
+
9
+ ```python
10
+ from shepherd import Shepherd
11
+
12
+ shep = Shepherd("shep_live_...") # or Shepherd.create_sandbox() for an instant test key
13
+
14
+ wallet = shep.wallets.connect("0x...", "base", end_user_id="cust_4821")
15
+ pnl = shep.statements.income(start_date="2026-09-01", end_date="2026-09-30", end_user_id="cust_4821")
16
+ print(pnl["net_income"])
17
+ ```
18
+
19
+ ## Sandbox
20
+
21
+ ```python
22
+ shep = Shepherd.create_sandbox(email="you@company.com")
23
+ print(shep.sandbox_info) # seeded wallet + customer
24
+ ```
25
+
26
+ Sandbox keys start with `shep_test_`. Data is synthetic; connecting any address returns generated activity.
27
+
28
+ ## Webhooks
29
+
30
+ ```python
31
+ from shepherd import construct_event, SignatureVerificationError
32
+
33
+ # Flask example
34
+ @app.post("/shepherd/webhook")
35
+ def hook():
36
+ try:
37
+ event = construct_event(request.get_data(), request.headers["Shepherd-Signature"], WEBHOOK_SECRET)
38
+ except SignatureVerificationError:
39
+ return "", 400
40
+ if event["type"] == "wallet.scan_completed":
41
+ ...
42
+ return "", 200
43
+ ```
44
+
45
+ ## Push your own transactions
46
+
47
+ ```python
48
+ acct = shep.accounts.create("acct_123", type="bank", currency="USD", end_user_id="cust_4821")
49
+ r = shep.accounts.push_transactions(acct["id"], [
50
+ {"external_id": "tx_1", "date": "2026-09-03", "amount": 1250.00, "direction": "inflow", "counterparty": "Acme Corp"},
51
+ ])
52
+ print(r["created"], r["duplicates"], r["rejected"])
53
+ shep.jobs.wait(r["classification"]["job_id"]) # classification runs in the background
54
+ ```
55
+
56
+ Sending the same `external_id` again changes nothing, so it is safe to retry.
57
+
58
+ ## Pagination
59
+
60
+ ```python
61
+ for tx in shep.transactions.iterate(status="unclassified"): # follows pages for you
62
+ ...
63
+ changed = shep.transactions.iterate(updated_since="2026-10-01T00:00:00Z") # keep a copy in sync
64
+ ```
65
+
66
+ ## One set of books per customer (platform accounts)
67
+
68
+ ```python
69
+ shep.workspaces.create("cust_4821", "Acme Corp")
70
+ acme = shep.with_workspace("cust_4821") # every call on `acme` reads and writes that customer only
71
+ acme.statements.income(start_date="2026-09-01")
72
+ ```
73
+
74
+ ## Keys and safe retries
75
+
76
+ ```python
77
+ key = shep.api_keys.create(label="reporting", scope="read", expires_in_days=90) # admin key needed
78
+ shep.api_keys.rotate(key["id"], grace_hours=24)
79
+ shep.accounts.create("acct_123", idempotency_key="create-acct_123") # POSTs with a key retry safely
80
+ ```
81
+
82
+ ## Errors
83
+
84
+ Every failure raises a `ShepherdError` subclass with `.status`, `.message`, `.code`, `.request_id`, `.body`:
85
+ `InvalidRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `.retry_after`), `ServerError` (5xx).
86
+ GET requests, and POSTs sent with an `idempotency_key`, retry automatically on 429/502/503 and honour `Retry-After`.
87
+
88
+ ## Resources
89
+
90
+ `wallets`, `transactions`, `accounts`, `jobs`, `statements`, `entities`, `opening_balances`, `accruals`, `classification_rules`, `chart_of_accounts`, `periods`, `reconciliation`, `audit_log`, `api_keys`, `workspaces`, `webhooks`, `sandbox`. See the [API reference](https://getshepherd.co/developers-docs.html#reference).
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shepherd-sdk"
7
+ version = "0.2.0"
8
+ description = "Python client for the Shepherd API: connect wallets, classify transactions, and pull financial statements."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Shepherd", email = "alan@getshepherd.co" }]
13
+ dependencies = ["requests>=2.25"]
14
+ classifiers = ["Programming Language :: Python :: 3", "Typing :: Typed", "License :: OSI Approved :: MIT License"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://getshepherd.co/developers.html"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["shepherd"]
@@ -0,0 +1,8 @@
1
+ from .client import Shepherd, DEFAULT_BASE_URL
2
+ from .errors import (AuthenticationError, ConflictError, InvalidRequestError, NotFoundError, PermissionDeniedError,
3
+ RateLimitError, ServerError, ShepherdError, SignatureVerificationError)
4
+ from .webhooks import construct_event
5
+
6
+ __all__ = ["Shepherd", "DEFAULT_BASE_URL", "ShepherdError", "AuthenticationError", "InvalidRequestError", "NotFoundError", "PermissionDeniedError", "ConflictError",
7
+ "RateLimitError", "ServerError", "SignatureVerificationError", "construct_event"]
8
+ __version__ = "0.2.0"
@@ -0,0 +1,405 @@
1
+ import time
2
+
3
+ import requests
4
+
5
+ from . import webhooks as _wh
6
+ from .errors import (AuthenticationError, ConflictError, InvalidRequestError, NotFoundError, PermissionDeniedError,
7
+ RateLimitError, ServerError, ShepherdError)
8
+
9
+ DEFAULT_BASE_URL = "https://app.getshepherd.co/api/v1"
10
+
11
+
12
+ def _clean(d):
13
+ return {k: v for k, v in d.items() if v is not None}
14
+
15
+
16
+ class _Resource:
17
+ def __init__(self, client):
18
+ self._c = client
19
+
20
+
21
+ class Wallets(_Resource):
22
+ def list(self, end_user_id=None):
23
+ return self._c._get("/wallets", end_user_id=end_user_id)["wallets"]
24
+
25
+ def connect(self, address, chain, end_user_id=None, end_user_name=None, name=None, lookback_blocks=None):
26
+ """Connect a wallet, pull its history, and auto-classify. chain: base, ethereum, polygon, arbitrum, tempo, solana."""
27
+ return self._c._post("/wallets", _clean(dict(address=address, chain=chain, end_user_id=end_user_id,
28
+ end_user_name=end_user_name, name=name, lookback_blocks=lookback_blocks)))
29
+
30
+ def scan(self, wallet_id, background=False):
31
+ """Rescan a wallet. With background=True returns a job right away (see `jobs.wait`)."""
32
+ return self._c._post(f"/wallets/{wallet_id}/scan", {"async": True} if background else None)
33
+
34
+
35
+ class Transactions(_Resource):
36
+ def list(self, wallet_id=None, end_user_id=None, gl_code=None, **filters):
37
+ """One page (up to `limit`, default 500). Use `iterate` to read everything, or `page` for the cursor.
38
+ filters: status, date_from, date_to, updated_since, limit, cursor, order."""
39
+ return self.page(wallet_id=wallet_id, end_user_id=end_user_id, gl_code=gl_code, **filters)["transactions"]
40
+
41
+ def page(self, **filters):
42
+ """One page as {transactions, has_more, next_cursor}."""
43
+ return self._c._get("/transactions", **filters)
44
+
45
+ def iterate(self, **filters):
46
+ """Yields every matching transaction, following next_cursor until the end."""
47
+ return self._c._iterate("/transactions", "transactions", filters)
48
+
49
+ def get(self, transaction_id):
50
+ return self._c._get(f"/transactions/{transaction_id}")
51
+
52
+ def classify(self, transaction_id, gl_code, counterparty=None, reason=None):
53
+ return self._c._post(f"/transactions/{transaction_id}/classify", _clean(dict(gl_code=gl_code, counterparty=counterparty, reason=reason)))
54
+
55
+ def link_suggestions(self):
56
+ return self._c._get("/transactions/link-suggestions")["suggestions"]
57
+
58
+ def link(self, tx_id_a, tx_id_b, note=None):
59
+ return self._c._post("/transactions/link", _clean(dict(tx_id_a=tx_id_a, tx_id_b=tx_id_b, note=note)))
60
+
61
+ def links(self):
62
+ return self._c._get("/transactions/links")["links"]
63
+
64
+ def unlink(self, link_id):
65
+ return self._c._post(f"/transactions/links/{link_id}/unlink")
66
+
67
+ def dismiss_link_suggestion(self, tx_id_a, tx_id_b):
68
+ return self._c._post("/transactions/link-suggestions/dismiss", dict(tx_id_a=tx_id_a, tx_id_b=tx_id_b))
69
+
70
+
71
+ class Statements(_Resource):
72
+ def income(self, start_date=None, end_date=None, entity_id=None, wallet_id=None, end_user_id=None):
73
+ return self._c._get("/financial-statements", start_date=start_date, end_date=end_date, entity_id=entity_id,
74
+ wallet_id=wallet_id, end_user_id=end_user_id)
75
+
76
+ def income_consolidated(self, start_date=None, end_date=None):
77
+ return self._c._get("/financial-statements/consolidated", start_date=start_date, end_date=end_date)
78
+
79
+ def balance_sheet(self, entity_id=None, as_of_date=None):
80
+ return self._c._get("/balance-sheet", entity_id=entity_id, as_of_date=as_of_date)
81
+
82
+ def balance_sheet_consolidated(self, as_of_date=None, period_start_date=None):
83
+ return self._c._get("/balance-sheet/consolidated", as_of_date=as_of_date, period_start_date=period_start_date)
84
+
85
+
86
+ class Entities(_Resource):
87
+ def list(self):
88
+ return self._c._get("/entities")["entities"]
89
+
90
+ def create(self, name, legal_name=None, entity_type=None, functional_currency=None):
91
+ return self._c._post("/entities", _clean(dict(name=name, legal_name=legal_name, entity_type=entity_type, functional_currency=functional_currency)))
92
+
93
+ def update(self, entity_id, name, legal_name=None, entity_type=None, functional_currency=None, clear_functional_currency=None):
94
+ return self._c._post(f"/entities/{entity_id}/update", _clean(dict(name=name, legal_name=legal_name, entity_type=entity_type,
95
+ functional_currency=functional_currency,
96
+ clear_functional_currency=clear_functional_currency)))
97
+
98
+ def delete(self, entity_id):
99
+ return self._c._post(f"/entities/{entity_id}/delete")
100
+
101
+
102
+ class OpeningBalances(_Resource):
103
+ def list(self):
104
+ return self._c._get("/opening-balances")["opening_balances"]
105
+
106
+ def create(self, account_name, account_type, amount, as_of_date, currency=None, gl_code=None):
107
+ return self._c._post("/opening-balances", _clean(dict(account_name=account_name, account_type=account_type, amount=amount,
108
+ as_of_date=as_of_date, currency=currency, gl_code=gl_code)))
109
+
110
+ def delete(self, balance_id):
111
+ return self._c._post(f"/opening-balances/{balance_id}/delete")
112
+
113
+
114
+ class Accruals(_Resource):
115
+ def list(self):
116
+ return self._c._get("/accruals")["accruals"]
117
+
118
+ def create(self, entry_type, amount, issue_date, **fields):
119
+ """fields: gl_code, counterparty, description, due_date, recognition_start_date, recognition_months, external_source, external_id"""
120
+ return self._c._post("/accruals", _clean(dict(entry_type=entry_type, amount=amount, issue_date=issue_date, **fields)))
121
+
122
+ def match(self, accrual_id, tx_hash, wallet_address, chain, matched_date):
123
+ return self._c._post(f"/accruals/{accrual_id}/match", dict(tx_hash=tx_hash, wallet_address=wallet_address, chain=chain, matched_date=matched_date))
124
+
125
+ def delete(self, accrual_id):
126
+ return self._c._post(f"/accruals/{accrual_id}/delete")
127
+
128
+
129
+ class ClassificationRules(_Resource):
130
+ def list(self):
131
+ return self._c._get("/classification-rules")["classification_rules"]
132
+
133
+ def create(self, gl_code, direction=None, chain=None, counterparty=None, amount_min=None, amount_max=None, entity_id=None):
134
+ return self._c._post("/classification-rules", _clean(dict(gl_code=gl_code, direction=direction, chain=chain, counterparty=counterparty,
135
+ amount_min=amount_min, amount_max=amount_max, entity_id=entity_id)))
136
+
137
+ def delete(self, rule_id):
138
+ return self._c._post(f"/classification-rules/{rule_id}/delete")
139
+
140
+
141
+ class Reconciliation(_Resource):
142
+ def get(self, wallet_id):
143
+ return self._c._get("/reconciliation", wallet_id=wallet_id)
144
+
145
+ def set_baseline(self, wallet_id, balance):
146
+ return self._c._post("/reconciliation/baseline", dict(wallet_id=wallet_id, balance=balance))
147
+
148
+
149
+ class Accounts(_Resource):
150
+ """Accounts you push transactions to: bank, credit_card, stablecoin or ledger."""
151
+
152
+ def list(self, end_user_id=None):
153
+ return self._c._get("/accounts", end_user_id=end_user_id)["accounts"]
154
+
155
+ def create(self, external_id, name=None, type=None, currency=None, end_user_id=None, end_user_name=None, entity_id=None,
156
+ idempotency_key=None):
157
+ """Creates the account, or returns the existing one with the same external_id."""
158
+ return self._c._post("/accounts", _clean(dict(external_id=external_id, name=name, type=type, currency=currency,
159
+ end_user_id=end_user_id, end_user_name=end_user_name, entity_id=entity_id)),
160
+ idempotency_key=idempotency_key)
161
+
162
+ def get(self, account_id):
163
+ return self._c._get(f"/accounts/{account_id}")
164
+
165
+ def push_transactions(self, account_id, transactions, classify=True, idempotency_key=None):
166
+ """Send up to 1000 transactions: dicts with external_id, date, amount, and optionally direction, counterparty,
167
+ currency, category. Returns {created, duplicates, rejected, ids, classification}. Safe to resend."""
168
+ return self._c._post(f"/accounts/{account_id}/transactions", dict(transactions=transactions, classify=classify),
169
+ idempotency_key=idempotency_key)
170
+
171
+ def transactions(self, account_id, **filters):
172
+ """Every transaction of one account, following pages."""
173
+ return self._c._iterate(f"/accounts/{account_id}/transactions", "transactions", filters)
174
+
175
+ def classify(self, account_id):
176
+ """Classify the account's unclassified transactions in the background. Returns a job."""
177
+ return self._c._post(f"/accounts/{account_id}/classify")
178
+
179
+
180
+ class Jobs(_Resource):
181
+ def list(self):
182
+ return self._c._get("/jobs")["jobs"]
183
+
184
+ def get(self, job_id):
185
+ return self._c._get(f"/jobs/{job_id}")
186
+
187
+ def wait(self, job_id, timeout=300, interval=2):
188
+ """Polls until the job succeeds (returns it) or fails (raises ShepherdError)."""
189
+ deadline = time.time() + timeout
190
+ while True:
191
+ job = self.get(job_id)
192
+ if job["status"] == "succeeded":
193
+ return job
194
+ if job["status"] == "failed":
195
+ raise ShepherdError(0, job.get("error") or "job failed", job)
196
+ if time.time() > deadline:
197
+ raise ShepherdError(0, f"job {job_id} still {job['status']} after {timeout}s", job)
198
+ time.sleep(interval)
199
+
200
+
201
+ class ApiKeys(_Resource):
202
+ """Needs an admin key."""
203
+
204
+ def list(self):
205
+ return self._c._get("/api-keys")["api_keys"]
206
+
207
+ def create(self, label=None, scope=None, expires_in_days=None, rate_limit_per_min=None):
208
+ """scope: read, write or admin. The secret in `api_key` is shown once."""
209
+ return self._c._post("/api-keys", _clean(dict(label=label, scope=scope, expires_in_days=expires_in_days,
210
+ rate_limit_per_min=rate_limit_per_min)))
211
+
212
+ def rotate(self, key_id, grace_hours=None):
213
+ """Issues a replacement; the old key keeps working for grace_hours (default 24)."""
214
+ return self._c._post(f"/api-keys/{key_id}/rotate", _clean(dict(grace_hours=grace_hours)))
215
+
216
+ def revoke(self, key_id):
217
+ return self._c._post(f"/api-keys/{key_id}/revoke")
218
+
219
+
220
+ class Workspaces(_Resource):
221
+ """One separate set of books per customer. Use `shep.with_workspace(external_id)` to work inside one."""
222
+
223
+ def list(self):
224
+ return list(self._c._iterate("/workspaces", "workspaces", {}))
225
+
226
+ def create(self, external_id, name, contact_email=None, idempotency_key=None):
227
+ return self._c._post("/workspaces", _clean(dict(external_id=external_id, name=name, contact_email=contact_email)),
228
+ idempotency_key=idempotency_key)
229
+
230
+ def get(self, workspace_id):
231
+ return self._c._get(f"/workspaces/{workspace_id}")
232
+
233
+ def update(self, workspace_id, name):
234
+ return self._c._post(f"/workspaces/{workspace_id}/update", dict(name=name))
235
+
236
+ def delete(self, workspace_id, confirm):
237
+ """Permanently deletes the workspace and its data. `confirm` must equal its external_id."""
238
+ return self._c._post(f"/workspaces/{workspace_id}/delete", dict(confirm=confirm))
239
+
240
+
241
+ class ChartOfAccounts(_Resource):
242
+ def list(self):
243
+ return self._c._get("/chart-of-accounts")["accounts"]
244
+
245
+ def add(self, accounts, replace=False):
246
+ """accounts: dicts with code, name, account_type, description, parent_code. replace=True needs an admin key."""
247
+ return self._c._post("/chart-of-accounts", dict(accounts=accounts, replace=replace))
248
+
249
+
250
+ class Periods(_Resource):
251
+ def get(self):
252
+ return self._c._get("/periods")
253
+
254
+ def close(self, through, note=None):
255
+ return self._c._post("/periods/close", _clean(dict(through=through, note=note)))
256
+
257
+ def reopen(self, to, note):
258
+ return self._c._post("/periods/reopen", dict(to=to, note=note))
259
+
260
+
261
+ class AuditLog(_Resource):
262
+ def iterate(self, limit=None):
263
+ return self._c._iterate("/audit-log", "entries", _clean(dict(limit=limit)))
264
+
265
+
266
+ class Webhooks(_Resource):
267
+ def list(self):
268
+ return self._c._get("/webhooks")["webhooks"]
269
+
270
+ def create(self, url, events=None, description=None):
271
+ """Returns the endpoint including its signing `secret`. The secret is shown once."""
272
+ return self._c._post("/webhooks", _clean(dict(url=url, events=events, description=description)))
273
+
274
+ def delete(self, webhook_id):
275
+ return self._c._post(f"/webhooks/{webhook_id}/delete")
276
+
277
+ def test(self, webhook_id):
278
+ """Queue a `ping` event to this endpoint."""
279
+ return self._c._post(f"/webhooks/{webhook_id}/test")
280
+
281
+ def deliveries(self, webhook_id):
282
+ return self._c._get(f"/webhooks/{webhook_id}/deliveries")["deliveries"]
283
+
284
+ construct_event = staticmethod(_wh.construct_event)
285
+
286
+
287
+ class Sandbox(_Resource):
288
+ def reset(self):
289
+ """Wipe this sandbox back to its seed data. Sandbox keys only."""
290
+ return self._c._post("/sandbox/reset")
291
+
292
+
293
+ class Shepherd:
294
+ def __init__(self, api_key, base_url=DEFAULT_BASE_URL, timeout=60, max_retries=2, workspace=None):
295
+ if not api_key:
296
+ raise ValueError("api_key is required")
297
+ self.api_key = api_key
298
+ self.base_url = base_url.rstrip("/")
299
+ self.timeout = timeout
300
+ self.max_retries = max_retries
301
+ self.workspace = workspace
302
+ self.livemode = not api_key.startswith("shep_test_")
303
+ self._s = requests.Session()
304
+ self._s.headers.update({"Authorization": f"Bearer {api_key}", "User-Agent": "shepherd-python/0.2.0"})
305
+ if workspace:
306
+ self._s.headers["Shepherd-Workspace"] = str(workspace)
307
+ self.wallets = Wallets(self)
308
+ self.transactions = Transactions(self)
309
+ self.statements = Statements(self)
310
+ self.entities = Entities(self)
311
+ self.opening_balances = OpeningBalances(self)
312
+ self.accruals = Accruals(self)
313
+ self.classification_rules = ClassificationRules(self)
314
+ self.reconciliation = Reconciliation(self)
315
+ self.webhooks = Webhooks(self)
316
+ self.sandbox = Sandbox(self)
317
+ self.accounts = Accounts(self)
318
+ self.jobs = Jobs(self)
319
+ self.api_keys = ApiKeys(self)
320
+ self.workspaces = Workspaces(self)
321
+ self.chart_of_accounts = ChartOfAccounts(self)
322
+ self.periods = Periods(self)
323
+ self.audit_log = AuditLog(self)
324
+
325
+ def with_workspace(self, external_id):
326
+ """A client that works inside one customer's workspace (platform accounts)."""
327
+ return Shepherd(self.api_key, base_url=self.base_url, timeout=self.timeout, max_retries=self.max_retries, workspace=external_id)
328
+
329
+ def _iterate(self, path, key, params):
330
+ params = dict(params or {})
331
+ while True:
332
+ page = self._get(path, **params)
333
+ for item in page[key]:
334
+ yield item
335
+ if not page.get("has_more"):
336
+ return
337
+ params["cursor"] = page["next_cursor"]
338
+
339
+ @classmethod
340
+ def create_sandbox(cls, email=None, name=None, base_url=DEFAULT_BASE_URL):
341
+ """Mint an instant sandbox key (no signup form) and return a client for it. The seed details are on `.sandbox_info`."""
342
+ r = requests.post(base_url.rstrip("/") + "/sandbox/signup", json=_clean(dict(email=email, name=name)), timeout=30)
343
+ body = _json(r)
344
+ if not r.ok:
345
+ raise _error(r.status_code, body)
346
+ client = cls(body["api_key"], base_url=base_url)
347
+ client.sandbox_info = body
348
+ return client
349
+
350
+ def _request(self, method, path, params=None, json_body=None, idempotency_key=None):
351
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
352
+ retriable = method == "GET" or bool(idempotency_key) # a POST is only retried when it is safe to repeat
353
+ attempt = 0
354
+ while True:
355
+ try:
356
+ r = self._s.request(method, self.base_url + path, params=_clean(params or {}) or None, json=json_body,
357
+ headers=headers, timeout=self.timeout)
358
+ except requests.RequestException as e:
359
+ if retriable and attempt < self.max_retries:
360
+ attempt += 1
361
+ time.sleep(0.5 * 2 ** attempt)
362
+ continue
363
+ raise ShepherdError(0, f"network error: {e}")
364
+ body = _json(r)
365
+ if r.ok:
366
+ return body
367
+ if retriable and r.status_code in (429, 502, 503) and attempt < self.max_retries:
368
+ attempt += 1
369
+ try:
370
+ wait = min(30.0, float(r.headers.get("Retry-After")))
371
+ except (TypeError, ValueError):
372
+ wait = 0.5 * 2 ** attempt
373
+ time.sleep(wait)
374
+ continue
375
+ raise _error(r.status_code, body, r.headers)
376
+
377
+ def _get(self, path, **params):
378
+ return self._request("GET", path, params=params)
379
+
380
+ def _post(self, path, body=None, idempotency_key=None):
381
+ return self._request("POST", path, json_body=body if body is not None else {}, idempotency_key=idempotency_key)
382
+
383
+
384
+ def _json(r):
385
+ try:
386
+ return r.json()
387
+ except ValueError:
388
+ return {"error": r.text[:200]}
389
+
390
+
391
+ def _error(status, body, headers=None):
392
+ body = body or {}
393
+ msg = body.get("message") or body.get("error") or "request failed"
394
+ cls = {400: InvalidRequestError, 401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError,
395
+ 409: ConflictError, 413: InvalidRequestError, 422: InvalidRequestError, 429: RateLimitError}.get(status)
396
+ if cls is None:
397
+ cls = ServerError if status >= 500 else ShepherdError
398
+ err = cls(status, msg, body)
399
+ err.code = body.get("code")
400
+ err.request_id = body.get("request_id")
401
+ try:
402
+ err.retry_after = float((headers or {}).get("Retry-After"))
403
+ except (TypeError, ValueError):
404
+ err.retry_after = None
405
+ return err
@@ -0,0 +1,43 @@
1
+ class ShepherdError(Exception):
2
+ """Raised for any non-2xx response. `status` is the HTTP status, `message` is the API's `error` text."""
3
+
4
+ def __init__(self, status, message, body=None):
5
+ super().__init__(f"{status}: {message}")
6
+ self.status = status
7
+ self.message = message
8
+ self.body = body or {}
9
+ self.code = self.body.get("code") # stable, safe to branch on, e.g. "period_closed"
10
+ self.request_id = self.body.get("request_id") # quote this when contacting Shepherd
11
+ self.retry_after = None # seconds, set on 429 and 503
12
+
13
+
14
+ class AuthenticationError(ShepherdError):
15
+ """401: missing, malformed, expired or revoked API key."""
16
+
17
+
18
+ class PermissionDeniedError(ShepherdError):
19
+ """403: the key's scope is too low, or the account can't use this feature."""
20
+
21
+
22
+ class ConflictError(ShepherdError):
23
+ """409: for example the period is closed, or the same Idempotency-Key is still running."""
24
+
25
+
26
+ class NotFoundError(ShepherdError):
27
+ """404: the object doesn't exist in your organization."""
28
+
29
+
30
+ class InvalidRequestError(ShepherdError):
31
+ """400: a field is missing or invalid."""
32
+
33
+
34
+ class RateLimitError(ShepherdError):
35
+ """429: slow down."""
36
+
37
+
38
+ class ServerError(ShepherdError):
39
+ """5xx: Shepherd (or a chain data source) failed. Safe to retry."""
40
+
41
+
42
+ class SignatureVerificationError(Exception):
43
+ """A webhook payload failed signature verification."""
File without changes
@@ -0,0 +1,29 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import time
5
+
6
+ from .errors import SignatureVerificationError
7
+
8
+
9
+ def construct_event(payload, signature_header, secret, tolerance=300):
10
+ """Verify a webhook and return the parsed event.
11
+
12
+ payload the raw request body, exactly as received (bytes or str), not re-serialized JSON
13
+ signature_header the value of the `Shepherd-Signature` header
14
+ secret your endpoint's signing secret (whsec_...)
15
+ tolerance seconds a timestamp may differ from now; guards against replays (default 5 minutes)
16
+ """
17
+ if isinstance(payload, bytes):
18
+ payload = payload.decode("utf-8")
19
+ try:
20
+ parts = dict(p.split("=", 1) for p in signature_header.split(","))
21
+ ts, sig = int(parts["t"]), parts["v1"]
22
+ except Exception:
23
+ raise SignatureVerificationError("Malformed Shepherd-Signature header")
24
+ expected = hmac.new(secret.encode(), f"{ts}.{payload}".encode(), hashlib.sha256).hexdigest()
25
+ if not hmac.compare_digest(expected, sig):
26
+ raise SignatureVerificationError("Signature does not match")
27
+ if tolerance and abs(time.time() - ts) > tolerance:
28
+ raise SignatureVerificationError("Timestamp outside the tolerance window")
29
+ return json.loads(payload)
@@ -0,0 +1,140 @@
1
+ """Runs against a live server: SHEPHERD_TEST_BASE=http://localhost:8191/api/v1 python -m pytest tests"""
2
+ import os, time, hmac, hashlib, json
3
+ import pytest
4
+ from shepherd import Shepherd, NotFoundError, InvalidRequestError, AuthenticationError, SignatureVerificationError, construct_event
5
+
6
+ BASE = os.environ.get("SHEPHERD_TEST_BASE")
7
+ pytestmark = pytest.mark.skipif(not BASE, reason="set SHEPHERD_TEST_BASE")
8
+
9
+
10
+ @pytest.fixture(scope="module")
11
+ def shep():
12
+ return Shepherd.create_sandbox(email="sdk-test@example.com", base_url=BASE)
13
+
14
+
15
+ def test_sandbox_seed(shep):
16
+ assert shep.livemode is False
17
+ ws = shep.wallets.list()
18
+ assert len(ws) == 1 and shep.sandbox_info["seeded"]["end_user_id"] == "cust_demo"
19
+
20
+
21
+ def test_statements_and_filters(shep):
22
+ txs = shep.transactions.list(end_user_id="cust_demo")
23
+ assert len(txs) == 40
24
+ pnl = shep.statements.income(start_date="2020-01-01", end_user_id="cust_demo")
25
+ assert pnl["total_revenue"] > 0
26
+ assert shep.statements.balance_sheet() is not None
27
+
28
+
29
+ def test_connect_scan_classify(shep):
30
+ w = shep.wallets.connect("0xdef4560000000000000000000000000000000def", "polygon", end_user_id="cust_x", end_user_name="X")
31
+ assert w["transactions_saved"] == 40
32
+ assert shep.wallets.scan(w["wallet_id"])["success"]
33
+ unc = shep.transactions.list(wallet_id=w["wallet_id"], gl_code="Unclassified") or [t for t in shep.transactions.list(wallet_id=w["wallet_id"]) if not t.get("gl_code")]
34
+ assert shep.transactions.classify(unc[0]["id"], "6600 - Office & Operations")["success"]
35
+
36
+
37
+ def test_errors(shep):
38
+ with pytest.raises(InvalidRequestError):
39
+ shep.wallets.connect("0x1", "dogechain")
40
+ with pytest.raises(NotFoundError):
41
+ shep.wallets.scan(999999)
42
+ with pytest.raises(AuthenticationError):
43
+ Shepherd("shep_test_nope", base_url=BASE).wallets.list()
44
+
45
+
46
+ def test_entities_rules(shep):
47
+ e = shep.entities.create("Customer A", functional_currency="USD")
48
+ assert any(x["name"] == "Customer A" for x in shep.entities.list())
49
+ shep.classification_rules.create("6200 - Software & SaaS", counterparty="Vercel")
50
+ assert len(shep.classification_rules.list()) == 1
51
+ shep.opening_balances.create("Cash", "asset", 1000, "2026-01-01")
52
+ assert len(shep.opening_balances.list()) == 1
53
+
54
+
55
+ def test_webhook_signature():
56
+ body = json.dumps({"id": "evt_1", "type": "ping"})
57
+ ts = int(time.time())
58
+ sig = hmac.new(b"whsec_x", f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
59
+ assert construct_event(body, f"t={ts},v1={sig}", "whsec_x")["id"] == "evt_1"
60
+ with pytest.raises(SignatureVerificationError):
61
+ construct_event(body, f"t={ts},v1=deadbeef", "whsec_x")
62
+ old = ts - 1000
63
+ sig_old = hmac.new(b"whsec_x", f"{old}.{body}".encode(), hashlib.sha256).hexdigest()
64
+ with pytest.raises(SignatureVerificationError):
65
+ construct_event(body, f"t={old},v1={sig_old}", "whsec_x")
66
+
67
+
68
+ def test_reset(shep):
69
+ shep.sandbox.reset()
70
+ assert len(shep.wallets.list()) == 1
71
+
72
+
73
+ # ---- 0.2.0: accounts, pagination, jobs, keys, workspaces
74
+ from shepherd import PermissionDeniedError, ConflictError
75
+
76
+
77
+ def test_push_and_paginate(shep):
78
+ acct = shep.accounts.create("sdk_acct", name="SDK bank", type="bank", currency="USD", end_user_id="cust_sdk", idempotency_key="sdk-acct-1")
79
+ again = shep.accounts.create("sdk_acct", name="SDK bank", type="bank", currency="USD", end_user_id="cust_sdk", idempotency_key="sdk-acct-1")
80
+ assert again["id"] == acct["id"]
81
+ txs = [dict(external_id=f"s{i}", date="2026-08-%02d" % (1 + i % 28), amount=10 + i, direction="outflow", counterparty="Vendor %d" % (i % 5)) for i in range(250)]
82
+ r = shep.accounts.push_transactions(acct["id"], txs, classify=False)
83
+ assert r["created"] == 250 and r["rejected"] == []
84
+ r2 = shep.accounts.push_transactions(acct["id"], txs[:10], classify=False)
85
+ assert r2["created"] == 0 and r2["duplicates"] == 10
86
+ bad = shep.accounts.push_transactions(acct["id"], [dict(external_id="bad", date="nope", amount=1)], classify=False)
87
+ assert len(bad["rejected"]) == 1
88
+ seen = list(shep.accounts.transactions(acct["id"], limit=100))
89
+ assert len(seen) == 250 and len({t["id"] for t in seen}) == 250
90
+ assert len(list(shep.transactions.iterate(wallet_id=acct["id"], limit=60))) == 250
91
+ assert shep.transactions.page(wallet_id=acct["id"], limit=10)["has_more"] is True
92
+ assert shep.transactions.get(seen[0]["id"])["id"] == seen[0]["id"]
93
+
94
+
95
+ def test_jobs(shep):
96
+ acct = shep.accounts.create("sdk_acct2", type="stablecoin")
97
+ shep.accounts.push_transactions(acct["id"], [dict(external_id="j1", date="2026-08-03", amount=5, counterparty="Acme")], classify=False)
98
+ job = shep.accounts.classify(acct["id"])
99
+ done = shep.jobs.wait(job["job_id"], timeout=60, interval=1)
100
+ assert done["status"] == "succeeded"
101
+ assert any(j["id"] == job["job_id"] for j in shep.jobs.list())
102
+
103
+
104
+ def test_keys_scopes_errors(shep):
105
+ ro = shep.api_keys.create(label="ro", scope="read")
106
+ reader = Shepherd(ro["api_key"], base_url=BASE)
107
+ assert reader.transactions.list(limit=1) is not None
108
+ with pytest.raises(PermissionDeniedError) as ei:
109
+ reader.accounts.create("nope")
110
+ assert ei.value.code == "insufficient_scope" and ei.value.request_id
111
+ rot = shep.api_keys.rotate(ro["id"], grace_hours=1)
112
+ assert rot["replaces"] == ro["id"]
113
+ shep.api_keys.revoke(rot["id"])
114
+ with pytest.raises(AuthenticationError) as ei:
115
+ Shepherd(rot["api_key"], base_url=BASE).wallets.list()
116
+ assert ei.value.code == "invalid_api_key"
117
+ assert any(k["id"] == ro["id"] for k in shep.api_keys.list())
118
+
119
+
120
+ def test_workspaces(shep):
121
+ ws = shep.workspaces.create("sdk_cust_1", "SDK Customer 1")
122
+ inside = shep.with_workspace("sdk_cust_1")
123
+ assert inside.accounts.list() == []
124
+ a = inside.accounts.create("w1", type="bank")
125
+ inside.accounts.push_transactions(a["id"], [dict(external_id="w", date="2026-08-02", amount=7, counterparty="Z")], classify=False)
126
+ assert len(list(inside.transactions.iterate())) == 1
127
+ assert all(x["id"] != a["id"] for x in shep.accounts.list())
128
+ assert any(w["external_id"] == "sdk_cust_1" for w in shep.workspaces.list())
129
+ with pytest.raises(InvalidRequestError):
130
+ shep.workspaces.delete("sdk_cust_1", confirm="wrong")
131
+ assert shep.workspaces.delete("sdk_cust_1", confirm="sdk_cust_1")["success"]
132
+ with pytest.raises(NotFoundError):
133
+ shep.workspaces.get("sdk_cust_1")
134
+
135
+
136
+ def test_chart_periods_audit(shep):
137
+ assert len(shep.chart_of_accounts.list()) > 5
138
+ assert shep.chart_of_accounts.add([dict(code="6901 - SDK Test", name="SDK Test", account_type="expense")])["received"] == 1
139
+ assert "closed_through" in shep.periods.get()
140
+ assert len(list(zip(range(5), shep.audit_log.iterate(limit=2)))) == 5