From 5ac31552c3f9184222089af710bc9d9023375eeb Mon Sep 17 00:00:00 2001 From: courier-codegen Date: Fri, 4 Sep 2026 23:43:48 +0000 Subject: [PATCH] feat(api): document the expiry_date contract and allow boolean patch values --- AGENTS.md | 53 ------------------ src/courier/resources/users/tokens.py | 16 ++++-- src/courier/types/email_footer.py | 56 ++++++++++++++++++- src/courier/types/email_footer_param.py | 56 ++++++++++++++++++- .../types/users/token_add_single_params.py | 8 ++- .../types/users/token_update_params.py | 10 +++- src/courier/types/users/user_token.py | 8 ++- src/courier_docs/lib/.keep | 4 -- tests/api_resources/test_brands.py | 36 ++++++++++-- 9 files changed, 169 insertions(+), 78 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 src/courier_docs/lib/.keep diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 56972f4e..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Courier Python SDK - -Courier is a notifications API for sending messages across email, SMS, push, in-app inbox, Slack, and WhatsApp from a single API call. - -## Setup - -```python -from courier import Courier - -client = Courier() # reads COURIER_API_KEY from env -``` - -For async usage, use `AsyncCourier` and `await`. - -## Core pattern - -```python -response = client.send.message( - message={ - "to": {"user_id": "user_123"}, - "template": "TEMPLATE_ID", - "data": {"order_id": "456"}, - "routing": {"method": "single", "channels": ["email", "sms"]}, - }, -) - -print(response.request_id) -``` - -## Key rules - -- Use `routing.method: "single"` (fallback chain) unless the user explicitly asks for parallel delivery (`"all"`). -- Use `client.profiles.create()` for partial profile updates (it merges). Use `client.profiles.replace()` only when fully replacing all profile data. -- Test and production use different API keys from the same workspace. Always confirm which environment before sending. -- For transactional sends (OTP, orders, billing), pass an `Idempotency-Key` header via `extra_headers` to prevent duplicates. -- Bulk sends are a 3-step flow: `client.bulk.create()` → `client.bulk.add_users()` → `client.bulk.run()`. -- `request_id` from a single-recipient send doubles as the `message_id`. For multi-recipient sends, each recipient gets a unique `message_id`. - -## Concepts - -- `template` — notification template ID from the Courier dashboard -- `routing.method` — `"single"` = try channels in order until one succeeds; `"all"` = send on every channel simultaneously -- `tenant_id` — multi-tenant context; affects brand and preference defaults for the message -- `list_id` — send to all subscribers of a named list -- `to.email` / `to.phone_number` — ad-hoc recipient (no stored profile needed) -- `to.user_id` — registered user whose profile has channel addresses - -## More context - -- Full docs index: https://www.courier.com/docs/llms.txt -- API reference: https://www.courier.com/docs/reference/get-started -- MCP server: https://mcp.courier.com -- Courier Skills (Cursor / Claude Code): https://github.com/trycourier/courier-skills diff --git a/src/courier/resources/users/tokens.py b/src/courier/resources/users/tokens.py index c43acb01..f2a83a99 100644 --- a/src/courier/resources/users/tokens.py +++ b/src/courier/resources/users/tokens.py @@ -257,8 +257,12 @@ def add_single( Args: device: Information about the device the token came from. - expiry_date: ISO 8601 formatted date the token expires. Defaults to 2 months. Set to false to - disable expiration. + expiry_date: When the token expires. Accepts a date, or the boolean `false` to disable + expiration entirely. ISO 8601 is recommended (for example + `2026-10-25T00:00:00.000Z`). A value that cannot be parsed as a date is + rejected; it is not treated as "no expiration" and does not fall back to the + default. `true` is not a supported value. Omit the field to use the default, + which expires a token that has not been re-registered for 60 days. properties: Properties about the token. @@ -528,8 +532,12 @@ async def add_single( Args: device: Information about the device the token came from. - expiry_date: ISO 8601 formatted date the token expires. Defaults to 2 months. Set to false to - disable expiration. + expiry_date: When the token expires. Accepts a date, or the boolean `false` to disable + expiration entirely. ISO 8601 is recommended (for example + `2026-10-25T00:00:00.000Z`). A value that cannot be parsed as a date is + rejected; it is not treated as "no expiration" and does not fall back to the + default. `true` is not a supported value. Omit the field to use the default, + which expires a token that has not been re-registered for 60 days. properties: Properties about the token. diff --git a/src/courier/types/email_footer.py b/src/courier/types/email_footer.py index 56a656f8..04bd6250 100644 --- a/src/courier/types/email_footer.py +++ b/src/courier/types/email_footer.py @@ -6,10 +6,60 @@ from .._models import BaseModel -__all__ = ["EmailFooter"] +__all__ = [ + "EmailFooter", + "Social", + "SocialFacebook", + "SocialInstagram", + "SocialLinkedin", + "SocialMedium", + "SocialTwitter", +] -class EmailFooter(BaseModel): - content: Optional[str] = None +class SocialFacebook(BaseModel): + url: Optional[str] = None + + +class SocialInstagram(BaseModel): + url: Optional[str] = None + + +class SocialLinkedin(BaseModel): + url: Optional[str] = None + + +class SocialMedium(BaseModel): + url: Optional[str] = None + + +class SocialTwitter(BaseModel): + url: Optional[str] = None + + +class Social(BaseModel): + """Social links rendered in the email footer.""" + facebook: Optional[SocialFacebook] = None + + instagram: Optional[SocialInstagram] = None + + linkedin: Optional[SocialLinkedin] = None + + medium: Optional[SocialMedium] = None + + twitter: Optional[SocialTwitter] = None + + +class EmailFooter(BaseModel): inherit_default: Optional[bool] = FieldInfo(alias="inheritDefault", default=None) + + markdown: Optional[str] = None + """The footer body, as markdown. + + This is the field the API returns and accepts; it is omitted entirely when no + footer body is set. Sending null is accepted and treated as no footer body. + """ + + social: Optional[Social] = None + """Social links rendered in the email footer.""" diff --git a/src/courier/types/email_footer_param.py b/src/courier/types/email_footer_param.py index b8326ca5..c8d981b1 100644 --- a/src/courier/types/email_footer_param.py +++ b/src/courier/types/email_footer_param.py @@ -7,10 +7,60 @@ from .._utils import PropertyInfo -__all__ = ["EmailFooterParam"] +__all__ = [ + "EmailFooterParam", + "Social", + "SocialFacebook", + "SocialInstagram", + "SocialLinkedin", + "SocialMedium", + "SocialTwitter", +] -class EmailFooterParam(TypedDict, total=False): - content: Optional[str] +class SocialFacebook(TypedDict, total=False): + url: Optional[str] + + +class SocialInstagram(TypedDict, total=False): + url: Optional[str] + + +class SocialLinkedin(TypedDict, total=False): + url: Optional[str] + + +class SocialMedium(TypedDict, total=False): + url: Optional[str] + + +class SocialTwitter(TypedDict, total=False): + url: Optional[str] + + +class Social(TypedDict, total=False): + """Social links rendered in the email footer.""" + facebook: Optional[SocialFacebook] + + instagram: Optional[SocialInstagram] + + linkedin: Optional[SocialLinkedin] + + medium: Optional[SocialMedium] + + twitter: Optional[SocialTwitter] + + +class EmailFooterParam(TypedDict, total=False): inherit_default: Annotated[Optional[bool], PropertyInfo(alias="inheritDefault")] + + markdown: Optional[str] + """The footer body, as markdown. + + This is the field the API returns and accepts; it is omitted entirely when no + footer body is set. Sending null is accepted and treated as no footer body. + """ + + social: Optional[Social] + """Social links rendered in the email footer.""" diff --git a/src/courier/types/users/token_add_single_params.py b/src/courier/types/users/token_add_single_params.py index 151ea841..714b9a1b 100644 --- a/src/courier/types/users/token_add_single_params.py +++ b/src/courier/types/users/token_add_single_params.py @@ -17,9 +17,13 @@ class TokenAddSingleParams(TypedDict, total=False): """Information about the device the token came from.""" expiry_date: Union[str, bool, None] - """ISO 8601 formatted date the token expires. + """When the token expires. - Defaults to 2 months. Set to false to disable expiration. + Accepts a date, or the boolean `false` to disable expiration entirely. ISO 8601 + is recommended (for example `2026-10-25T00:00:00.000Z`). A value that cannot be + parsed as a date is rejected; it is not treated as "no expiration" and does not + fall back to the default. `true` is not a supported value. Omit the field to use + the default, which expires a token that has not been re-registered for 60 days. """ properties: object diff --git a/src/courier/types/users/token_update_params.py b/src/courier/types/users/token_update_params.py index 535c5e4d..d27a7e9b 100644 --- a/src/courier/types/users/token_update_params.py +++ b/src/courier/types/users/token_update_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Iterable, Optional +from typing import Dict, Union, Iterable from typing_extensions import Required, TypedDict __all__ = ["TokenUpdateParams", "Patch"] @@ -21,5 +21,9 @@ class Patch(TypedDict, total=False): path: Required[str] """The JSON path specifying the part of the profile to operate on.""" - value: Optional[str] - """The value for the operation.""" + value: Union[str, bool, Dict[str, object], None] + """The value for the operation. + + A string for most fields; boolean `false` when disabling token expiration via + `expiry_date`, which cannot be expressed as a string. + """ diff --git a/src/courier/types/users/user_token.py b/src/courier/types/users/user_token.py index bff090f1..ced16920 100644 --- a/src/courier/types/users/user_token.py +++ b/src/courier/types/users/user_token.py @@ -56,9 +56,13 @@ class UserToken(BaseModel): """Information about the device the token came from.""" expiry_date: Union[str, bool, None] = None - """ISO 8601 formatted date the token expires. + """When the token expires. - Defaults to 2 months. Set to false to disable expiration. + Accepts a date, or the boolean `false` to disable expiration entirely. ISO 8601 + is recommended (for example `2026-10-25T00:00:00.000Z`). A value that cannot be + parsed as a date is rejected; it is not treated as "no expiration" and does not + fall back to the default. `true` is not a supported value. Omit the field to use + the default, which expires a token that has not been re-registered for 60 days. """ properties: Optional[object] = None diff --git a/src/courier_docs/lib/.keep b/src/courier_docs/lib/.keep deleted file mode 100644 index 5e2c99fd..00000000 --- a/src/courier_docs/lib/.keep +++ /dev/null @@ -1,4 +0,0 @@ -File generated from our OpenAPI spec by Stainless. - -This directory can be used to store custom files to expand the SDK. -It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/tests/api_resources/test_brands.py b/tests/api_resources/test_brands.py index ca65aaef..6802fd0e 100644 --- a/tests/api_resources/test_brands.py +++ b/tests/api_resources/test_brands.py @@ -41,8 +41,15 @@ def test_method_create_with_all_params(self, client: Courier) -> None: }, "email": { "footer": { - "content": "content", "inherit_default": True, + "markdown": "markdown", + "social": { + "facebook": {"url": "url"}, + "instagram": {"url": "url"}, + "linkedin": {"url": "url"}, + "medium": {"url": "url"}, + "twitter": {"url": "url"}, + }, }, "head": { "inherit_default": True, @@ -202,8 +209,15 @@ def test_method_update_with_all_params(self, client: Courier) -> None: }, "email": { "footer": { - "content": "content", "inherit_default": True, + "markdown": "markdown", + "social": { + "facebook": {"url": "url"}, + "instagram": {"url": "url"}, + "linkedin": {"url": "url"}, + "medium": {"url": "url"}, + "twitter": {"url": "url"}, + }, }, "head": { "inherit_default": True, @@ -410,8 +424,15 @@ async def test_method_create_with_all_params(self, async_client: AsyncCourier) - }, "email": { "footer": { - "content": "content", "inherit_default": True, + "markdown": "markdown", + "social": { + "facebook": {"url": "url"}, + "instagram": {"url": "url"}, + "linkedin": {"url": "url"}, + "medium": {"url": "url"}, + "twitter": {"url": "url"}, + }, }, "head": { "inherit_default": True, @@ -571,8 +592,15 @@ async def test_method_update_with_all_params(self, async_client: AsyncCourier) - }, "email": { "footer": { - "content": "content", "inherit_default": True, + "markdown": "markdown", + "social": { + "facebook": {"url": "url"}, + "instagram": {"url": "url"}, + "linkedin": {"url": "url"}, + "medium": {"url": "url"}, + "twitter": {"url": "url"}, + }, }, "head": { "inherit_default": True,