railhook 2.12.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,536 @@
1
+ Metadata-Version: 2.4
2
+ Name: railhook
3
+ Version: 2.12.0
4
+ Summary: Official Python SDK for Railhook — reliable webhook infrastructure
5
+ Author-email: Vadym Kykalo <vadymkykalo@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/vadymkykalo/railhook
8
+ Project-URL: Repository, https://github.com/vadymkykalo/railhook
9
+ Project-URL: Documentation, https://github.com/vadymkykalo/railhook#readme
10
+ Keywords: webhook,webhooks,api,events,delivery
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: requests>=2.28.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
27
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
28
+ Requires-Dist: types-requests>=2.28.0; extra == "dev"
29
+
30
+ # railhook
31
+
32
+ Official Python SDK for [Railhook](https://github.com/vadymkykalo/railhook).
33
+
34
+ ```bash
35
+ pip install railhook
36
+ ```
37
+
38
+ > Published as `webhook-platform` before 2.12.0, importable as `hookflow`. That
39
+ > package is not updated any further; install `railhook` and change the import.
40
+
41
+ **Scope.** This SDK covers Events, Endpoints, Subscriptions, Deliveries,
42
+ Incoming Sources, Incoming Events, and webhook signature verification —
43
+ 7 of the platform's 35 API controllers. It does not wrap
44
+ Transformations, Rules, Workflows, Schemas, DLQ, Analytics, Usage, Alerts,
45
+ Incidents, PII rules, Audit Log, Tunnels, API keys, Members, or Projects —
46
+ use the [Generic Requests](#generic-requests) helpers for those until the
47
+ SDK grows to cover them.
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ pip install railhook
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ import os
59
+
60
+ from railhook import Railhook, Event
61
+
62
+ client = Railhook(
63
+ api_key=os.environ["RAILHOOK_API_KEY"], # e.g. "Kz1uAIM8VeJUQN7yGSYCst64WxNLabBHfOYbrPlJ1yk"
64
+ base_url="http://localhost:8080", # optional
65
+ )
66
+
67
+ # Send an event
68
+ event = client.events.send(
69
+ Event(
70
+ type="order.completed",
71
+ data={
72
+ "order_id": "ord_12345",
73
+ "amount": 99.99,
74
+ "currency": "USD",
75
+ },
76
+ )
77
+ )
78
+
79
+ print(f"Event created: {event.event_id}")
80
+ print(f"Deliveries created: {event.deliveries_created}")
81
+ ```
82
+
83
+ ## API Reference
84
+
85
+ ### Events
86
+
87
+ ```python
88
+ from railhook import Event
89
+
90
+ # Send event with idempotency key
91
+ event = client.events.send(
92
+ Event(type="order.completed", data={"order_id": "123"}),
93
+ idempotency_key="unique-key",
94
+ )
95
+ ```
96
+
97
+ ### Endpoints
98
+
99
+ ```python
100
+ from railhook import EndpointCreateParams, EndpointUpdateParams
101
+
102
+ # Create endpoint
103
+ endpoint = client.endpoints.create(
104
+ project_id,
105
+ EndpointCreateParams(
106
+ url="https://api.example.com/webhooks",
107
+ description="Production webhooks",
108
+ enabled=True,
109
+ ),
110
+ )
111
+
112
+ # List endpoints — the API paginates this one, so the endpoints are in .content
113
+ # (iterating the page yields them directly)
114
+ page = client.endpoints.list(project_id, page=0, size=20)
115
+ for endpoint in page:
116
+ print(endpoint.url)
117
+
118
+ # Update endpoint
119
+ client.endpoints.update(
120
+ project_id,
121
+ endpoint_id,
122
+ EndpointUpdateParams(enabled=False),
123
+ )
124
+
125
+ # Delete endpoint
126
+ client.endpoints.delete(project_id, endpoint_id)
127
+
128
+ # Rotate secret
129
+ updated = client.endpoints.rotate_secret(project_id, endpoint_id)
130
+ print(f"New secret: {updated.secret}")
131
+
132
+ # Test endpoint connectivity
133
+ result = client.endpoints.test(project_id, endpoint_id)
134
+ print(f"Test {'passed' if result.success else 'failed'}: {result.latency_ms}ms")
135
+ print(f"{result.http_status_code} — {result.message}")
136
+ ```
137
+
138
+ ### Subscriptions
139
+
140
+ ```python
141
+ from railhook import SubscriptionCreateParams
142
+
143
+ # Subscribe endpoint to an event type
144
+ subscription = client.subscriptions.create(
145
+ project_id,
146
+ SubscriptionCreateParams(
147
+ endpoint_id=endpoint.id,
148
+ event_type="order.completed",
149
+ enabled=True,
150
+ ),
151
+ )
152
+
153
+ # List subscriptions — a bare list; unlike endpoints, this one is not paginated
154
+ subscriptions = client.subscriptions.list(project_id)
155
+
156
+ # Update subscription
157
+ client.subscriptions.update(
158
+ project_id,
159
+ subscription_id,
160
+ event_type="order.shipped",
161
+ )
162
+
163
+ # Delete subscription
164
+ client.subscriptions.delete(project_id, subscription_id)
165
+ ```
166
+
167
+ ### Deliveries
168
+
169
+ ```python
170
+ from railhook import DeliveryListParams, DeliveryStatus
171
+
172
+ # List deliveries with filters
173
+ deliveries = client.deliveries.list(
174
+ project_id,
175
+ DeliveryListParams(status=DeliveryStatus.FAILED, page=0, size=20),
176
+ )
177
+
178
+ print(f"Total failed: {deliveries.total_elements}")
179
+
180
+ # Get delivery attempts
181
+ attempts = client.deliveries.get_attempts(delivery_id)
182
+ for attempt in attempts:
183
+ print(f"Attempt {attempt.attempt_number}: {attempt.http_status_code} ({attempt.duration_ms}ms)")
184
+
185
+ # Replay failed delivery
186
+ client.deliveries.replay(delivery_id)
187
+ ```
188
+
189
+ ## Incoming Webhooks
190
+
191
+ Receive, validate, and forward webhooks from third-party providers (Stripe, GitHub, Twilio, etc.).
192
+
193
+ ### Incoming Sources
194
+
195
+ ```python
196
+ from railhook import IncomingSourceCreateParams, IncomingSourceUpdateParams
197
+
198
+ # Create an incoming source with HMAC verification
199
+ source = client.incoming_sources.create(
200
+ project_id,
201
+ IncomingSourceCreateParams(
202
+ name="Stripe Webhooks",
203
+ slug="stripe",
204
+ provider_type="STRIPE",
205
+ verification_mode="HMAC_GENERIC",
206
+ hmac_secret="whsec_...",
207
+ hmac_header_name="Stripe-Signature",
208
+ ),
209
+ )
210
+
211
+ print(f"Ingress URL: {source.ingress_url}")
212
+
213
+ # List sources
214
+ sources = client.incoming_sources.list(project_id)
215
+
216
+ # Update source
217
+ client.incoming_sources.update(
218
+ project_id,
219
+ source_id,
220
+ IncomingSourceUpdateParams(name="Stripe Production", rate_limit_per_second=100),
221
+ )
222
+
223
+ # Delete source
224
+ client.incoming_sources.delete(project_id, source_id)
225
+ ```
226
+
227
+ ### Incoming Destinations
228
+
229
+ ```python
230
+ from railhook import IncomingDestinationCreateParams
231
+
232
+ # Add a forwarding destination
233
+ dest = client.incoming_sources.create_destination(
234
+ project_id,
235
+ source_id,
236
+ IncomingDestinationCreateParams(
237
+ url="https://your-api.com/webhooks/stripe",
238
+ enabled=True,
239
+ max_attempts=5,
240
+ timeout_seconds=30,
241
+ ),
242
+ )
243
+
244
+ # List destinations
245
+ dests = client.incoming_sources.list_destinations(project_id, source_id)
246
+
247
+ # Delete destination
248
+ client.incoming_sources.delete_destination(project_id, source_id, dest_id)
249
+ ```
250
+
251
+ ### Incoming Events
252
+
253
+ ```python
254
+ from railhook import IncomingEventListParams
255
+
256
+ # List incoming events (with optional source filter)
257
+ events = client.incoming_events.list(
258
+ project_id,
259
+ IncomingEventListParams(source_id=source.id, page=0, size=20),
260
+ )
261
+
262
+ # Get event details
263
+ event = client.incoming_events.get(project_id, event_id)
264
+
265
+ # Get forward attempts
266
+ attempts = client.incoming_events.get_attempts(project_id, event_id)
267
+
268
+ # Replay event to all destinations
269
+ result = client.incoming_events.replay(project_id, event_id)
270
+ print(f"Replayed to {result.destinations_count} destinations")
271
+ ```
272
+
273
+ ## Webhook Signature Verification
274
+
275
+ Verify incoming webhooks in your endpoint:
276
+
277
+ ```python
278
+ from railhook import verify_signature, construct_event, RailhookError
279
+
280
+ # Flask example
281
+ from flask import Flask, request
282
+
283
+ app = Flask(__name__)
284
+
285
+ @app.route("/webhooks", methods=["POST"])
286
+ def handle_webhook():
287
+ payload = request.get_data(as_text=True)
288
+ headers = dict(request.headers)
289
+ secret = os.environ["WEBHOOK_SECRET"]
290
+
291
+ try:
292
+ # Option 1: Just verify
293
+ verify_signature(payload, headers.get("X-Signature", ""), secret)
294
+
295
+ # Option 2: Verify and parse
296
+ event = construct_event(payload, headers, secret)
297
+
298
+ # event.data is the parsed body; event.event_id / event.delivery_id /
299
+ # event.timestamp come from the X-Event-Id / X-Delivery-Id /
300
+ # X-Timestamp headers. See "What lands on your endpoint" below for
301
+ # event.type.
302
+ print(f"Delivery {event.delivery_id} of event {event.event_id}: {event.data}")
303
+ handle_order_completed(event.data)
304
+
305
+ return "OK", 200
306
+
307
+ except RailhookError as e:
308
+ print(f"Webhook verification failed: {e.message}")
309
+ return "Invalid signature", 400
310
+ ```
311
+
312
+ ### What lands on your endpoint
313
+
314
+ Railhook PUTs the event's **payload** on the wire, not an envelope. This:
315
+
316
+ ```python
317
+ client.events.send(Event(type="order.completed", data={"order_id": "ord_1"}))
318
+ ```
319
+
320
+ arrives at your endpoint as the ``data`` object alone —
321
+
322
+ ```http
323
+ POST /webhooks HTTP/1.1
324
+ Content-Type: application/json
325
+ X-Signature: t=1738000000000,v1=<hex hmac-sha256>
326
+ X-Timestamp: 1738000000000
327
+ X-Event-Id: 6f0e…
328
+ X-Delivery-Id: 91ab…
329
+ X-Sequence-Number: 0
330
+ Idempotency-Key: 6f0e…-<endpoint-id>
331
+
332
+ {"order_id":"ord_1"}
333
+ ```
334
+
335
+ So `construct_event` fills `event_id`, `delivery_id` and `timestamp` from the
336
+ headers and `data` from the body, but **`type` is empty**: the event type is
337
+ not on the wire for a default subscription. Route on the payload, on the
338
+ endpoint you registered, or set the subscription's `payload_template` to wrap
339
+ the event so `type` becomes part of the body.
340
+
341
+ The signature is computed over `f"{timestamp}.{raw_body}"` with HMAC-SHA256 and
342
+ the endpoint secret, and the server rejects timestamps more than **300
343
+ seconds** old — verify against the *raw* body bytes, before any JSON parse and
344
+ re-serialize.
345
+
346
+ ### FastAPI Example
347
+
348
+ ```python
349
+ from fastapi import FastAPI, Request, HTTPException
350
+ from railhook import construct_event, RailhookError
351
+
352
+ app = FastAPI()
353
+
354
+ @app.post("/webhooks")
355
+ async def handle_webhook(request: Request):
356
+ payload = await request.body()
357
+ headers = dict(request.headers)
358
+
359
+ try:
360
+ event = construct_event(
361
+ payload.decode("utf-8"),
362
+ headers,
363
+ os.environ["WEBHOOK_SECRET"],
364
+ )
365
+
366
+ # Process event...
367
+ return {"status": "ok"}
368
+
369
+ except RailhookError as e:
370
+ raise HTTPException(status_code=400, detail=e.message)
371
+ ```
372
+
373
+ ## Error Handling
374
+
375
+ ```python
376
+ from railhook import (
377
+ RailhookError,
378
+ RateLimitError,
379
+ AuthenticationError,
380
+ ValidationError,
381
+ )
382
+
383
+ try:
384
+ client.events.send(Event(type="test", data={}))
385
+ except RateLimitError as e:
386
+ # retry_after_ms is milliseconds. e.rate_limit_info.reset is the raw
387
+ # X-RateLimit-Reset header, which the API sends in Unix *seconds*.
388
+ print(f"Rate limited. Retry after {e.retry_after_ms}ms")
389
+ time.sleep(e.retry_after_ms / 1000)
390
+ except AuthenticationError:
391
+ print("Invalid API key")
392
+ except ValidationError as e:
393
+ print(f"Validation failed: {e.field_errors}")
394
+ except RailhookError as e:
395
+ print(f"Error {e.status}: {e.message}")
396
+ ```
397
+
398
+ ### Error Response Format
399
+
400
+ All API errors return a consistent JSON body:
401
+
402
+ ```json
403
+ {
404
+ "error": "error_code",
405
+ "message": "Human-readable description",
406
+ "status": 400,
407
+ "fieldErrors": { "field": "reason" }
408
+ }
409
+ ```
410
+
411
+ - **`error`** — machine-readable error code (`snake_case`), always present
412
+ - **`message`** — human-readable description, always present
413
+ - **`status`** — HTTP status code (integer), always present
414
+ - **`fieldErrors`** — field-level validation details (only present for `validation_error`)
415
+
416
+ ### Error Codes Reference
417
+
418
+ | HTTP Status | `error` Code | SDK Exception | Description |
419
+ |---|---|---|---|
420
+ | 400 | `validation_error` | `ValidationError` | Invalid request parameters; see `fieldErrors` |
421
+ | 400 | `invalid_request` | `RailhookError` | Malformed or semantically invalid request |
422
+ | 401 | `unauthorized` | `AuthenticationError` | Missing or invalid API key / expired token |
423
+ | 403 | `forbidden` | `RailhookError` | Insufficient permissions for the action |
424
+ | 404 | `not_found` | `NotFoundError` | Requested resource does not exist |
425
+ | 413 | `payload_too_large` | `RailhookError` | Request body exceeds maximum allowed size |
426
+ | 422 | `unprocessable_entity` | `RailhookError` | Valid syntax but violates business rules |
427
+ | 429 | `rate_limit_exceeded` | `RateLimitError` | Too many requests; check `X-RateLimit-*` headers |
428
+ | 500 | `internal_error` | `RailhookError` | Unexpected server error |
429
+
430
+ ## Generic Requests
431
+
432
+ As the API expands, you can call any endpoint directly without waiting for SDK updates:
433
+
434
+ ```python
435
+ # GET
436
+ schemas = client.get("/api/v1/projects/proj_123/schemas")
437
+
438
+ # GET with query params
439
+ items = client.get("/api/v1/projects/proj_123/items", params={"status": "active"})
440
+
441
+ # POST with body and idempotency key
442
+ result = client.post("/api/v1/some/new/endpoint", body={"key": "value"}, idempotency_key="unique-key")
443
+
444
+ # PUT
445
+ client.put("/api/v1/projects/proj_123/settings", body={"timezone": "UTC"})
446
+
447
+ # PATCH
448
+ client.patch("/api/v1/projects/proj_123/settings", body={"timezone": "UTC"})
449
+
450
+ # DELETE
451
+ client.delete("/api/v1/projects/proj_123/tags/old-tag")
452
+ ```
453
+
454
+ All generic methods use the same authentication, error handling, and rate-limit logic as the built-in methods.
455
+
456
+ ## Configuration
457
+
458
+ ```python
459
+ client = Railhook(
460
+ api_key=os.environ["RAILHOOK_API_KEY"], # Required: Your project API key
461
+ base_url="https://api.example.com", # Optional (default: http://localhost:8080)
462
+ timeout=30, # Optional: Request timeout in seconds (default: 30)
463
+ )
464
+ ```
465
+
466
+ ### Timeouts and retries
467
+
468
+ `timeout` is passed straight to `requests`; hitting it raises `RailhookError`
469
+ with `code="timeout"` and `status=0`. A connection-level failure raises the
470
+ same class with `code="network_error"`.
471
+
472
+ **The client does not retry.** One SDK call is exactly one HTTP request — no
473
+ backoff, no idempotent replay, and no `urllib3` `Retry` adapter is installed.
474
+ That is deliberate: `events.send` accepts an `idempotency_key`, so a retry
475
+ policy belongs to the caller who knows whether reissuing the request is safe.
476
+ What *is* retried is the delivery itself, by the platform, on the
477
+ subscription's `retry_delays` ladder.
478
+
479
+ ## Authentication
480
+
481
+ Every request the client makes carries `X-API-Key: <your key>` — the project
482
+ API key, created in the dashboard or via
483
+ `POST /api/v1/projects/{project_id}/api-keys`. The SDK never sends a bearer
484
+ token and has no login surface: JWT-authenticated endpoints (auth, projects,
485
+ organizations, members, API keys) are not part of it. Bootstrapping a project
486
+ and a key is a one-time step you do with the dashboard, the CLI, or plain
487
+ HTTP.
488
+
489
+ ## Type Hints
490
+
491
+ This SDK includes full type hints for better IDE support:
492
+
493
+ ```python
494
+ from railhook import (
495
+ Event,
496
+ EventResponse,
497
+ Endpoint,
498
+ Delivery,
499
+ DeliveryStatus,
500
+ )
501
+ ```
502
+
503
+ ## Development
504
+
505
+ ### Running Tests
506
+
507
+ **Local (requires Python 3.8+):**
508
+ ```bash
509
+ pip install -e ".[dev]"
510
+ pytest
511
+ ```
512
+
513
+ **Docker:**
514
+ ```bash
515
+ docker run --rm -v $(pwd):/app -w /app python:3.11-slim sh -c "pip install -e '.[dev]' && pytest"
516
+ ```
517
+
518
+ ### Live-API smoke check
519
+
520
+ `pytest` stubs the transport, so it cannot see a renamed field. To drive the
521
+ SDK against a real instance:
522
+
523
+ ```bash
524
+ make up # from the repo root
525
+ python scripts/live_api_smoke.py # SMOKE_API_BASE_URL overrides the target
526
+ ```
527
+
528
+ It registers a throwaway org, walks endpoint → subscription → event →
529
+ deliveries → attempts → incoming, checks each error envelope, and verifies a
530
+ signature the running server itself produced. It is not collected by `pytest`
531
+ (`testpaths = tests`, `python_files = test_*.py`), so the unit suite still
532
+ passes with no backend.
533
+
534
+ ## License
535
+
536
+ MIT