transcodely 0.2.0__py3-none-any.whl

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.
Files changed (86) hide show
  1. buf/__init__.py +0 -0
  2. buf/validate/__init__.py +0 -0
  3. buf/validate/validate_pb2.py +469 -0
  4. buf/validate/validate_pb2.pyi +651 -0
  5. transcodely/__init__.py +175 -0
  6. transcodely/_codec/__init__.py +0 -0
  7. transcodely/_codec/json_codec.py +193 -0
  8. transcodely/_transport/__init__.py +0 -0
  9. transcodely/_transport/error_mapping.py +102 -0
  10. transcodely/_transport/headers.py +31 -0
  11. transcodely/_transport/transport.py +312 -0
  12. transcodely/client.py +160 -0
  13. transcodely/errors.py +115 -0
  14. transcodely/pagination.py +53 -0
  15. transcodely/py.typed +0 -0
  16. transcodely/resources/__init__.py +0 -0
  17. transcodely/resources/_helpers.py +29 -0
  18. transcodely/resources/api_keys.py +48 -0
  19. transcodely/resources/apps.py +59 -0
  20. transcodely/resources/events.py +118 -0
  21. transcodely/resources/health.py +19 -0
  22. transcodely/resources/jobs.py +120 -0
  23. transcodely/resources/memberships.py +54 -0
  24. transcodely/resources/organizations.py +73 -0
  25. transcodely/resources/origins.py +55 -0
  26. transcodely/resources/presets.py +63 -0
  27. transcodely/resources/users.py +46 -0
  28. transcodely/resources/videos.py +114 -0
  29. transcodely/resources/webhook_endpoints.py +242 -0
  30. transcodely/streaming.py +47 -0
  31. transcodely/types.py +570 -0
  32. transcodely/v1/api_key_pb2.py +69 -0
  33. transcodely/v1/api_key_pb2.pyi +97 -0
  34. transcodely/v1/app_pb2.py +107 -0
  35. transcodely/v1/app_pb2.pyi +219 -0
  36. transcodely/v1/codec_av1_pb2.py +55 -0
  37. transcodely/v1/codec_av1_pb2.pyi +34 -0
  38. transcodely/v1/codec_h264_pb2.py +71 -0
  39. transcodely/v1/codec_h264_pb2.pyi +44 -0
  40. transcodely/v1/codec_h265_pb2.py +69 -0
  41. transcodely/v1/codec_h265_pb2.pyi +42 -0
  42. transcodely/v1/codec_vp9_pb2.py +57 -0
  43. transcodely/v1/codec_vp9_pb2.pyi +30 -0
  44. transcodely/v1/common_pb2.py +67 -0
  45. transcodely/v1/common_pb2.pyi +171 -0
  46. transcodely/v1/content_aware_pb2.py +56 -0
  47. transcodely/v1/content_aware_pb2.pyi +51 -0
  48. transcodely/v1/drm_pb2.py +65 -0
  49. transcodely/v1/drm_pb2.pyi +68 -0
  50. transcodely/v1/hdr_pb2.py +57 -0
  51. transcodely/v1/hdr_pb2.pyi +64 -0
  52. transcodely/v1/health_pb2.py +44 -0
  53. transcodely/v1/health_pb2.pyi +44 -0
  54. transcodely/v1/job_pb2.py +201 -0
  55. transcodely/v1/job_pb2.pyi +546 -0
  56. transcodely/v1/media_pb2.py +43 -0
  57. transcodely/v1/media_pb2.pyi +100 -0
  58. transcodely/v1/membership_pb2.py +71 -0
  59. transcodely/v1/membership_pb2.pyi +122 -0
  60. transcodely/v1/organization_pb2.py +81 -0
  61. transcodely/v1/organization_pb2.pyi +121 -0
  62. transcodely/v1/origin_pb2.py +157 -0
  63. transcodely/v1/origin_pb2.pyi +317 -0
  64. transcodely/v1/preset_pb2.py +192 -0
  65. transcodely/v1/preset_pb2.pyi +290 -0
  66. transcodely/v1/streaming_pb2.py +59 -0
  67. transcodely/v1/streaming_pb2.pyi +56 -0
  68. transcodely/v1/subtitles_pb2.py +69 -0
  69. transcodely/v1/subtitles_pb2.pyi +75 -0
  70. transcodely/v1/thumbnails_pb2.py +63 -0
  71. transcodely/v1/thumbnails_pb2.pyi +74 -0
  72. transcodely/v1/user_pb2.py +72 -0
  73. transcodely/v1/user_pb2.pyi +137 -0
  74. transcodely/v1/video_pb2.py +166 -0
  75. transcodely/v1/video_pb2.pyi +367 -0
  76. transcodely/v1/webhook_pb2.py +151 -0
  77. transcodely/v1/webhook_pb2.pyi +336 -0
  78. transcodely/version.py +11 -0
  79. transcodely/webhooks/__init__.py +44 -0
  80. transcodely/webhooks/construct_event.py +111 -0
  81. transcodely/webhooks/signature.py +110 -0
  82. transcodely/webhooks/types.py +115 -0
  83. transcodely-0.2.0.dist-info/METADATA +349 -0
  84. transcodely-0.2.0.dist-info/RECORD +86 -0
  85. transcodely-0.2.0.dist-info/WHEEL +4 -0
  86. transcodely-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,111 @@
1
+ """Verify a signed webhook delivery and decode it into a typed :class:`Event`.
2
+
3
+ Python port of the TypeScript SDK's ``webhooks/construct-event.ts``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import math
10
+ from typing import Any, Callable, Mapping, Optional, Union
11
+
12
+ from ..errors import WebhookPayloadError
13
+ from .signature import DEFAULT_TOLERANCE_SECONDS, verify_signature
14
+ from .types import Event, EventRequest, decoder_for_type
15
+
16
+
17
+ def construct_event(
18
+ raw_body: Union[str, bytes],
19
+ sig_header: str,
20
+ secret: Union[str, list[str]],
21
+ *,
22
+ tolerance: int = DEFAULT_TOLERANCE_SECONDS,
23
+ now: Optional[Callable[[], int]] = None,
24
+ ) -> Event:
25
+ """Verify a signed webhook delivery and return the typed :class:`Event`.
26
+
27
+ ``secret`` may be a single signing secret or a list of secrets — pass
28
+ ``[previous, current]`` during a secret rotation's overlap window.
29
+
30
+ :raises WebhookSignatureError: the signature is missing/malformed or doesn't match.
31
+ :raises WebhookTimestampError: the timestamp is outside the tolerance window.
32
+ :raises WebhookPayloadError: the body isn't valid JSON or the envelope shape is wrong.
33
+ """
34
+ body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
35
+
36
+ verify_signature(body_bytes, sig_header, secret, tolerance=tolerance, now=now)
37
+
38
+ try:
39
+ parsed = json.loads(body_bytes.decode("utf-8"))
40
+ except (ValueError, UnicodeDecodeError) as cause:
41
+ raise WebhookPayloadError("Webhook body is not valid JSON") from cause
42
+
43
+ return _build_event(parsed)
44
+
45
+
46
+ def _require_string(obj: Mapping[str, Any], key: str) -> str:
47
+ v = obj.get(key)
48
+ if not isinstance(v, str) or len(v) == 0:
49
+ raise WebhookPayloadError(f"Webhook envelope is missing required string field `{key}`")
50
+ return v
51
+
52
+
53
+ def _require_number(obj: Mapping[str, Any], key: str) -> int:
54
+ v = obj.get(key)
55
+ # bool is a subclass of int in Python — reject it explicitly so a JSON
56
+ # `true` doesn't sail through as the number 1 (matches the TS typeof check).
57
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v):
58
+ raise WebhookPayloadError(f"Webhook envelope field `{key}` must be a number")
59
+ return int(v)
60
+
61
+
62
+ def _build_event(parsed: Any) -> Event:
63
+ if not isinstance(parsed, dict):
64
+ raise WebhookPayloadError("Webhook body must be a JSON object")
65
+
66
+ id_ = _require_string(parsed, "id")
67
+ if parsed.get("object") != "event":
68
+ raise WebhookPayloadError('Webhook envelope `object` must be "event"')
69
+ api_version = _require_string(parsed, "api_version")
70
+ created = _require_string(parsed, "created")
71
+ type_ = _require_string(parsed, "type")
72
+ pending_webhooks = _require_number(parsed, "pending_webhooks")
73
+
74
+ data_raw = parsed.get("data")
75
+ if not isinstance(data_raw, dict):
76
+ raise WebhookPayloadError("Webhook envelope field `data` must be a JSON object")
77
+
78
+ request_raw = parsed.get("request")
79
+ if not isinstance(request_raw, dict):
80
+ raise WebhookPayloadError("Webhook envelope field `request` must be a JSON object")
81
+ # `request.id` is null for events emitted outside a request scope — every
82
+ # worker-callback event (job.succeeded/failed/canceled/progress, output.*,
83
+ # video.uploaded) and every SendTestWebhook delivery. Accept null or a
84
+ # non-empty string; reject "" and non-strings (mirrors the JS SDK).
85
+ request_id = request_raw.get("id")
86
+ if request_id is not None and (not isinstance(request_id, str) or request_id == ""):
87
+ raise WebhookPayloadError(
88
+ "Webhook envelope field `request.id` must be a non-empty string or null"
89
+ )
90
+ idempotency_key = request_raw.get("idempotency_key")
91
+ if idempotency_key is not None and not isinstance(idempotency_key, str):
92
+ raise WebhookPayloadError(
93
+ "Webhook envelope field `request.idempotency_key` must be a string or null"
94
+ )
95
+
96
+ decode = decoder_for_type(type_)
97
+ data: Any = decode(data_raw) if decode else data_raw
98
+
99
+ return Event(
100
+ id=id_,
101
+ object="event",
102
+ api_version=api_version,
103
+ created=created,
104
+ type=type_,
105
+ data=data,
106
+ pending_webhooks=pending_webhooks,
107
+ request=EventRequest(id=request_id, idempotency_key=idempotency_key),
108
+ )
109
+
110
+
111
+ __all__ = ["construct_event"]
@@ -0,0 +1,110 @@
1
+ """Webhook signature verification.
2
+
3
+ Python port of the TypeScript SDK's ``webhooks/signature.ts``. The signing
4
+ math is identical across all Transcodely SDKs: HMAC-SHA-256 over
5
+ ``"<unix-timestamp>.<raw-body-bytes>"``, hex-encoded, carried in the
6
+ ``Transcodely-Signature`` header as ``t=<ts>,v1=<hex>``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import time
14
+ from typing import Callable, Optional, Union
15
+
16
+ from ..errors import WebhookSignatureError, WebhookTimestampError
17
+
18
+ #: Default signature tolerance window in seconds (Stripe parity).
19
+ DEFAULT_TOLERANCE_SECONDS = 300
20
+
21
+ #: HTTP header name carrying the signature (lower-cased for case-insensitive lookup).
22
+ SIGNATURE_HEADER = "transcodely-signature"
23
+
24
+ #: HTTP header name carrying the event ID (``evt_*``). Useful as an idempotency
25
+ #: key for handlers that dedupe redeliveries — the same event ID rides every
26
+ #: retry of a given event.
27
+ EVENT_ID_HEADER = "webhook-id"
28
+
29
+
30
+ def _parse_header(header: str) -> tuple[int, list[str]]:
31
+ """Parse the ``Transcodely-Signature`` header.
32
+
33
+ The header is a comma-separated list of ``key=value`` pairs. ``t`` is the
34
+ unix timestamp (seconds); each ``v1`` entry is a hex-encoded
35
+ HMAC-SHA-256. Unknown keys are ignored so future scheme versions don't
36
+ break older receivers.
37
+ """
38
+ timestamp: Optional[int] = None
39
+ signatures: list[str] = []
40
+ for part in header.split(","):
41
+ eq = part.find("=")
42
+ if eq < 0:
43
+ continue
44
+ key = part[:eq].strip()
45
+ value = part[eq + 1 :].strip()
46
+ if key == "t":
47
+ try:
48
+ timestamp = int(value)
49
+ except ValueError:
50
+ # Divergence from the JS SDK (which parses with Number() and an
51
+ # isInteger check, so it would accept "123.0"/"1e3"): int() is
52
+ # stricter and rejects those. Harmless — the API always emits a
53
+ # plain integer timestamp.
54
+ continue
55
+ elif key == "v1":
56
+ signatures.append(value)
57
+ if timestamp is None:
58
+ raise WebhookSignatureError("Signature header is missing the timestamp (t=) component")
59
+ if not signatures:
60
+ raise WebhookSignatureError("Signature header has no v1 entries")
61
+ return timestamp, signatures
62
+
63
+
64
+ def verify_signature(
65
+ raw_body: Union[str, bytes],
66
+ sig_header: str,
67
+ secret: Union[str, list[str]],
68
+ *,
69
+ tolerance: int = DEFAULT_TOLERANCE_SECONDS,
70
+ now: Optional[Callable[[], int]] = None,
71
+ ) -> None:
72
+ """Verify a webhook signature. Raises on failure; returns ``None`` on success.
73
+
74
+ Multiple secrets are accepted so a customer rotating their signing secret
75
+ can pass ``[previous, current]`` during the overlap window without dropping
76
+ legitimate deliveries signed under either key.
77
+
78
+ :raises WebhookSignatureError: the header is malformed or no signature matched.
79
+ :raises WebhookTimestampError: the timestamp is outside ``tolerance`` seconds.
80
+ """
81
+ timestamp, signatures = _parse_header(sig_header)
82
+
83
+ now_fn: Callable[[], int] = now or (lambda: int(time.time()))
84
+ if abs(now_fn() - timestamp) > tolerance:
85
+ raise WebhookTimestampError(
86
+ f"Signature timestamp is outside the tolerance window ({tolerance}s)"
87
+ )
88
+
89
+ secrets = [secret] if isinstance(secret, str) else secret
90
+ body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
91
+ payload = f"{timestamp}.".encode("utf-8") + body_bytes
92
+
93
+ for s in secrets:
94
+ expected = hmac.new(s.encode("utf-8"), payload, hashlib.sha256).hexdigest()
95
+ for candidate in signatures:
96
+ # `expected` is lowercase hex (hexdigest); normalize the candidate so
97
+ # an upper/mixed-case hex digest still matches — the JS SDK compares
98
+ # decoded bytes, which is inherently case-insensitive.
99
+ if hmac.compare_digest(expected, candidate.lower()):
100
+ return
101
+
102
+ raise WebhookSignatureError("No signatures matched the expected value")
103
+
104
+
105
+ __all__ = [
106
+ "DEFAULT_TOLERANCE_SECONDS",
107
+ "EVENT_ID_HEADER",
108
+ "SIGNATURE_HEADER",
109
+ "verify_signature",
110
+ ]
@@ -0,0 +1,115 @@
1
+ """Public types for the webhook surface.
2
+
3
+ Python port of the TypeScript SDK's ``webhooks/types.ts`` — the customer-facing
4
+ :class:`Event` shape plus the resource-decoder table shared between the verify
5
+ helper (:func:`transcodely.construct_event`) and the ``client.events`` resource
6
+ bridge. Both paths converge on the same :class:`Event`, so a ``job.succeeded``
7
+ handler behaves identically whether the event arrived over an HTTP delivery or
8
+ was pulled from :meth:`Events.retrieve`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any, Callable, Literal, Mapping, Optional
15
+
16
+ from google.protobuf.message import Message
17
+
18
+ from ..resources._helpers import fill_from_dict
19
+ from ..v1 import app_pb2, job_pb2, video_pb2
20
+
21
+ #: The 13 canonical event types the webhook system can emit, mirroring the API's
22
+ #: ``domain.WebhookEventTypes()``. The ``"*"`` wildcard is intentionally absent —
23
+ #: it is a subscription value only, never the ``type`` of an emitted event. Used
24
+ #: for annotations and documentation; the ``Event.type`` field is a plain ``str``
25
+ #: so an older SDK still parses a ``type`` the API has since added (forward
26
+ #: compatibility).
27
+ EventType = Literal[
28
+ "job.created",
29
+ "job.succeeded",
30
+ "job.failed",
31
+ "job.canceled",
32
+ "job.progress",
33
+ "output.created",
34
+ "output.ready",
35
+ "output.failed",
36
+ "output.progress",
37
+ "video.uploaded",
38
+ "video.deleted",
39
+ "app.created",
40
+ "app.updated",
41
+ ]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class EventRequest:
46
+ """The originating API request that triggered an event, if known."""
47
+
48
+ #: Request ID (``req_*``), or ``None`` for events emitted outside a request
49
+ #: scope (worker-callback events like ``job.succeeded`` and every test send).
50
+ id: Optional[str]
51
+ #: Idempotency key the original request carried, or ``None``.
52
+ idempotency_key: Optional[str]
53
+
54
+
55
+ @dataclass(frozen=True, kw_only=True)
56
+ class Event:
57
+ """A webhook event, unified across the HTTP delivery envelope and the API.
58
+
59
+ ``data`` carries the decoded resource snapshot — a :class:`~transcodely.Job`,
60
+ :class:`~transcodely.JobOutput`, :class:`~transcodely.Video`, or
61
+ :class:`~transcodely.App` for a known ``type``, or the raw ``dict`` for an
62
+ unrecognized one (forward compatibility).
63
+ """
64
+
65
+ #: Event ID (``evt_*``).
66
+ id: str
67
+ #: Resource discriminator. Always ``"event"``.
68
+ object: str = "event"
69
+ #: API version at emit time (e.g. ``"2026-05-23"``). Frozen for the event's life.
70
+ api_version: str
71
+ #: RFC 3339 UTC timestamp of when the event was created.
72
+ created: str
73
+ #: Event type, e.g. ``"job.succeeded"``. See :data:`EventType`.
74
+ type: str
75
+ #: The decoded resource snapshot (a proto message, or ``dict`` for unknown types).
76
+ data: Any
77
+ #: Delivery attempts still pending across all subscribed endpoints.
78
+ pending_webhooks: int
79
+ #: The originating API request.
80
+ request: EventRequest
81
+
82
+
83
+ #: Alias matching the TypeScript SDK's ``WebhookEvent`` name.
84
+ WebhookEvent = Event
85
+
86
+ Decoder = Callable[[Mapping[str, Any]], Message]
87
+
88
+ #: Event-type prefix → resource decoder. Shared by ``construct_event`` (verify
89
+ #: path; ``data`` arrives pre-parsed) and the ``events`` resource bridge (API
90
+ #: path; ``data`` arrives as a JSON string on the proto ``Event``). ``output.``
91
+ #: is listed first to make the intent explicit; the prefixes are disjoint.
92
+ RESOURCE_DECODERS: list[tuple[str, Decoder]] = [
93
+ ("output.", lambda d: fill_from_dict(job_pb2.JobOutput(), d)),
94
+ ("job.", lambda d: fill_from_dict(job_pb2.Job(), d)),
95
+ ("video.", lambda d: fill_from_dict(video_pb2.Video(), d)),
96
+ ("app.", lambda d: fill_from_dict(app_pb2.App(), d)),
97
+ ]
98
+
99
+
100
+ def decoder_for_type(type: str) -> Optional[Decoder]:
101
+ """Return a resource decoder for ``type``, or ``None`` if the type is unknown."""
102
+ for prefix, decode in RESOURCE_DECODERS:
103
+ if type.startswith(prefix):
104
+ return decode
105
+ return None
106
+
107
+
108
+ __all__ = [
109
+ "Event",
110
+ "EventRequest",
111
+ "EventType",
112
+ "RESOURCE_DECODERS",
113
+ "WebhookEvent",
114
+ "decoder_for_type",
115
+ ]
@@ -0,0 +1,349 @@
1
+ Metadata-Version: 2.4
2
+ Name: transcodely
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for the Transcodely video transcoding API
5
+ Project-URL: Homepage, https://transcodely.com
6
+ Project-URL: Documentation, https://transcodely.com/docs
7
+ Project-URL: Repository, https://github.com/transcodely/transcodely-python
8
+ Project-URL: Issues, https://github.com/transcodely/transcodely-python/issues
9
+ Author: Transcodely
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: connect-rpc,dash,encoding,hls,streaming,transcodely,transcoding,video
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Multimedia :: Video :: Conversion
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: protobuf>=5.0
28
+ Requires-Dist: typing-extensions>=4.10
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy>=1.10; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.5; extra == 'dev'
34
+ Provides-Extra: fast
35
+ Requires-Dist: orjson>=3.10; extra == 'fast'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # transcodely
39
+
40
+ Official Python SDK for the [Transcodely](https://transcodely.com) video transcoding API.
41
+
42
+ ```bash
43
+ pip install transcodely
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ import os
50
+ from transcodely import Transcodely
51
+
52
+ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
53
+ job = client.jobs.create(
54
+ input_url="https://example.com/source.mp4",
55
+ outputs=[{
56
+ "type": "hls",
57
+ "video": [
58
+ {"codec": "h264", "resolution": "1080p"},
59
+ {"codec": "h264", "resolution": "720p"},
60
+ ],
61
+ }],
62
+ )
63
+ print(job.id) # "job_a1b2c3d4e5f6"
64
+
65
+ for event in client.jobs.watch(job.id):
66
+ print(event.job.status, event.job.progress)
67
+ if event.job.status == 4: # JOB_STATUS_COMPLETED
68
+ break
69
+ ```
70
+
71
+ The simplified-string form (`"hls"`, `"h264"`, `"1080p"`) is what the API actually emits over the wire — the SDK round-trips it transparently to and from the proto enum integers.
72
+
73
+ ## Authentication
74
+
75
+ ```python
76
+ client = Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"])
77
+ ```
78
+
79
+ API keys are opaque `ak_`-prefixed secrets — pass the full value shown once at creation time.
80
+
81
+ ## Resources
82
+
83
+ ```python
84
+ client.jobs # create / get / list / cancel / confirm / watch
85
+ client.videos # upload helpers, multipart, get / list / update / delete / watch
86
+ client.presets # create / get / get_by_slug / list / update / duplicate / archive
87
+ client.origins # create / get / list / update / validate / archive
88
+ client.apps # create / get / list / update / archive / enable_hosting
89
+ client.api_keys # create / get / list / revoke
90
+ client.organizations # create / get / list / update / check_slug
91
+ client.memberships # list / get / update_role / remove
92
+ client.users # get_me / get / list / update_me
93
+ client.health # check
94
+ client.webhook_endpoints # create / retrieve / update / delete / list / rotate_secret / send_test / list_deliveries / get_health
95
+ client.events # retrieve / list / resend
96
+ client.webhooks # construct_event / verify_signature (signature-verification helpers)
97
+ ```
98
+
99
+ ## Origins
100
+
101
+ An origin tells Transcodely where to read source media from and where to write outputs. Every origin belongs to a single provider; pass exactly one provider-config field (`s3`, `gcs`, `http`, or `r2`) on create.
102
+
103
+ ### Create an S3 origin
104
+
105
+ ```python
106
+ origin = client.origins.create(
107
+ name="Production S3",
108
+ permissions=["read", "write"],
109
+ s3={
110
+ "bucket": "my-bucket",
111
+ "region": "us-east-1",
112
+ "credentials": {
113
+ "access_key_id": os.environ["S3_ACCESS_KEY"],
114
+ "secret_access_key": os.environ["S3_SECRET_KEY"],
115
+ },
116
+ # "endpoint": "https://s3.custom.example.com", # for MinIO, Wasabi, etc.
117
+ },
118
+ )
119
+ ```
120
+
121
+ ### Create a GCS origin
122
+
123
+ ```python
124
+ origin = client.origins.create(
125
+ name="Production GCS",
126
+ permissions=["read", "write"],
127
+ gcs={
128
+ "bucket": "my-gcs-bucket",
129
+ "credentials": {
130
+ "service_account_json": os.environ["GCS_SERVICE_ACCOUNT_JSON"],
131
+ },
132
+ },
133
+ )
134
+ ```
135
+
136
+ ### Create an HTTP origin
137
+
138
+ ```python
139
+ origin = client.origins.create(
140
+ name="Public CDN",
141
+ permissions=["read"], # HTTP origins are read-only
142
+ http={
143
+ "base_url": "https://media.example.com",
144
+ "credentials": {
145
+ "headers": {"Authorization": f"Bearer {os.environ['MEDIA_TOKEN']}"},
146
+ },
147
+ },
148
+ )
149
+ ```
150
+
151
+ ### Create an R2 origin
152
+
153
+ R2 supports two forms. With `account_id` (32-char hex) the endpoint is derived for you, optionally with a data-residency jurisdiction:
154
+
155
+ ```python
156
+ origin = client.origins.create(
157
+ name="Production R2",
158
+ permissions=["read", "write"],
159
+ r2={
160
+ "bucket": "media",
161
+ "account_id": os.environ["R2_ACCOUNT_ID"],
162
+ "jurisdiction": "default", # or "eu", "fedramp"
163
+ "credentials": {
164
+ "access_key_id": os.environ["R2_ACCESS_KEY"],
165
+ "secret_access_key": os.environ["R2_SECRET_KEY"],
166
+ },
167
+ },
168
+ )
169
+ ```
170
+
171
+ Or, with an explicit `endpoint` (custom domain bound to a bucket, or a jurisdiction not yet enumerated):
172
+
173
+ ```python
174
+ r2 = {
175
+ "bucket": "media",
176
+ "endpoint": "https://media.example.com",
177
+ "credentials": {
178
+ "access_key_id": os.environ["R2_ACCESS_KEY"],
179
+ "secret_access_key": os.environ["R2_SECRET_KEY"],
180
+ },
181
+ }
182
+ ```
183
+
184
+ Provide either `account_id` or `endpoint`, never both. `jurisdiction` only applies when `account_id` is set.
185
+
186
+ ## Errors
187
+
188
+ All exceptions inherit from `TranscodelyError`:
189
+
190
+ ```python
191
+ from transcodely import (
192
+ Transcodely,
193
+ TranscodelyError,
194
+ InvalidRequestError,
195
+ NotFoundError,
196
+ RateLimitError,
197
+ )
198
+
199
+ try:
200
+ client.jobs.create(input_url=..., outputs=[...])
201
+ except InvalidRequestError as err:
202
+ for v in err.errors:
203
+ print(f"{v.field}: {v.description}")
204
+ except RateLimitError as err:
205
+ time.sleep((err.retry_after_ms or 1000) / 1000)
206
+ except TranscodelyError as err:
207
+ print(f"[{err.request_id}] {err.code}: {err}")
208
+ ```
209
+
210
+ | Class | Status | When |
211
+ |---|---|---|
212
+ | `APIConnectionError` | — | Network / DNS / TLS failure |
213
+ | `APIError` | 5xx | Server-side error |
214
+ | `AuthenticationError` | 401 | Bad / missing / revoked key |
215
+ | `PermissionError` | 403 | Authenticated but forbidden |
216
+ | `NotFoundError` | 404 | Resource doesn't exist |
217
+ | `ConflictError` | 409 | Idempotency conflict, slug taken |
218
+ | `RateLimitError` | 429 | Carries `retry_after_ms` |
219
+ | `InvalidRequestError` | 400 | Carries `errors: list[FieldViolation]` |
220
+ | `PreconditionError` | 412 | Wrong state (e.g. job not cancelable) |
221
+
222
+ Every error carries `request_id`, `code`, `http_status`, and `raw` for debugging.
223
+
224
+ Webhook verification raises `WebhookError` (or a subclass) — see [Webhooks](#webhooks).
225
+
226
+ ## Pagination
227
+
228
+ ```python
229
+ # One page
230
+ page = client.jobs.list(limit=50)
231
+ print(page.items, page.next_cursor)
232
+
233
+ # All items, automatically across pages
234
+ for job in client.jobs.list(limit=50).auto_paging_iter():
235
+ print(job.id)
236
+ ```
237
+
238
+ ## Idempotency
239
+
240
+ `jobs.create` accepts `idempotency_key`. If you don't pass one, the SDK generates a UUID v4 so retries are safe by default. For cross-process safety, pass your own:
241
+
242
+ ```python
243
+ client.jobs.create(
244
+ input_url="...",
245
+ outputs=[...],
246
+ idempotency_key="create-job-for-asset-12345",
247
+ )
248
+ ```
249
+
250
+ For all other write methods, the SDK ships an `Idempotency-Key` HTTP header automatically.
251
+
252
+ ## Streaming watch
253
+
254
+ ```python
255
+ for event in client.jobs.watch(job.id):
256
+ print(event.event, event.job.status, event.job.progress)
257
+ ```
258
+
259
+ The SDK auto-reconnects on transient network failures — Watch is read-only and re-emits a SNAPSHOT on every reconnect. HEARTBEAT events are filtered by default.
260
+
261
+ ## Webhooks
262
+
263
+ Verify a signed delivery and get back a typed `Event`. Pass the **raw** request body (bytes or str — do not re-serialize) and the `Transcodely-Signature` header:
264
+
265
+ ```python
266
+ from transcodely import construct_event, WebhookSignatureError
267
+
268
+ try:
269
+ event = construct_event(
270
+ request.body, # raw bytes/str, exactly as received
271
+ request.headers["transcodely-signature"],
272
+ "whsec_...", # your endpoint's signing secret
273
+ )
274
+ except WebhookSignatureError:
275
+ return Response(status_code=400)
276
+
277
+ # `event.data` is the decoded resource (a Job, JobOutput, Video, or App).
278
+ if event.type == "job.succeeded":
279
+ print("job done:", event.data.id)
280
+ elif event.type == "video.uploaded":
281
+ print("new video:", event.data.id)
282
+ ```
283
+
284
+ `construct_event` also accepts a **list** of secrets so deliveries keep verifying during a secret rotation's overlap window:
285
+
286
+ ```python
287
+ event = construct_event(body, sig_header, ["whsec_previous", "whsec_current"])
288
+ ```
289
+
290
+ Tuning and errors:
291
+
292
+ - `tolerance` (default `300` seconds) bounds clock skew / replay; widen or narrow it per call.
293
+ - `WebhookSignatureError` — header malformed or no signature matched.
294
+ - `WebhookTimestampError` — timestamp outside the tolerance window.
295
+ - `WebhookPayloadError` — body isn't valid JSON or doesn't match the event envelope.
296
+
297
+ All three inherit from `WebhookError`. `client.webhooks.construct_event(...)` is an alias for the module-level function.
298
+
299
+ Manage endpoints and replay events via the API:
300
+
301
+ ```python
302
+ endpoint = client.webhook_endpoints.create(
303
+ app_id="app_123",
304
+ url="https://example.com/hooks/transcodely",
305
+ enabled_events=["job.succeeded", "job.failed"], # or ["*"] for all
306
+ )
307
+ print(endpoint.secret) # shown only on create + rotate_secret — store it now
308
+
309
+ # The same typed Event, fetched from the API instead of an HTTP delivery:
310
+ event = client.events.retrieve("evt_123")
311
+ for event in client.events.list(app_id="app_123").auto_paging_iter():
312
+ print(event.type, event.id)
313
+
314
+ client.events.resend("evt_123") # re-queue delivery to all subscribed endpoints
315
+ ```
316
+
317
+ The 13 event types are `job.created`, `job.succeeded`, `job.failed`, `job.canceled`, `job.progress`, `output.created`, `output.ready`, `output.failed`, `output.progress`, `video.uploaded`, `video.deleted`, `app.created`, and `app.updated`. Subscribe to `"*"` to receive all of them (including ones added later). An unrecognized future type still verifies; its `event.data` is left as a plain `dict`.
318
+
319
+ ## Configuration
320
+
321
+ ```python
322
+ Transcodely(
323
+ api_key, # required
324
+ base_url=None, # default: https://api.transcodely.com
325
+ timeout=30.0, # seconds
326
+ max_retries=3,
327
+ api_version=None, # override the pinned API version
328
+ default_headers=None, # dict, sent on every request
329
+ http_client=None, # custom httpx.Client
330
+ logger=None, # callable(LogEvent)
331
+ )
332
+ ```
333
+
334
+ ## Request IDs
335
+
336
+ ```python
337
+ client.jobs.get("job_x")
338
+ print(client.last_request_id) # "req_*"
339
+ ```
340
+
341
+ Errors also carry the request ID via `err.request_id` for log correlation.
342
+
343
+ ## Versioning
344
+
345
+ The SDK follows semver, starting at `0.1.0`. Breaking changes are allowed on minor bumps until `1.0.0`. Each release pins a specific calendar-versioned API (`Transcodely.API_VERSION`) and sends `Transcodely-Version` on every request.
346
+
347
+ ## License
348
+
349
+ [MIT](LICENSE).