Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,50 @@ client.voice_in_trunks().update(created)
client.voice_in_trunks().delete(created.id)
```

#### SIP Registration (2026-04-16)

A Voice In Trunk can also authenticate inbound calls via SIP registration
credentials generated by DIDWW. The SDK auto-cascades the dependent fields
the server requires:

* setting `enabled_sip_registration = True` clears any previously-set
`host` / `port` (the server rejects them with 422 otherwise);
* setting `host` to a non-`None` value flips `enabled_sip_registration`
back to `False` and forces `use_did_in_ruri = False` so the server
accepts the disable PATCH.

The server generates `incoming_auth_username` and `incoming_auth_password`
and surfaces them in the response when SIP registration is enabled.

```python
sip = SipConfiguration()
sip.enabled_sip_registration = True
sip.use_did_in_ruri = True
sip.cnam_lookup = True

trunk = VoiceInTrunk()
trunk.name = "Office (registered)"
trunk.configuration = sip
created = client.voice_in_trunks().create(trunk).data

# created.configuration.incoming_auth_username — server-generated
# created.configuration.incoming_auth_password — server-generated
```

To disable SIP registration on an existing trunk, just set `host` — the
cascade flips `enabled_sip_registration` to `False` and `use_did_in_ruri`
to `False` automatically:

```python
disable = SipConfiguration()
disable.host = "sip.example.com"

trunk = VoiceInTrunk()
trunk.id = "trunk-uuid"
trunk.configuration = disable
client.voice_in_trunks().update(trunk)
```

### Voice In Trunk Groups

```python
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ DIDWW_API_KEY=your_api_key python examples/balance.py
| [`trunks.py`](trunks.py) | Creates SIP and PSTN trunks, prints details, then deletes them. |
| [`regions.py`](regions.py) | Lists regions, filters by country, and fetches a specific region. |
| [`voice_in_trunks.py`](voice_in_trunks.py) | Lists voice in trunks with their configurations and POP details. |
| [`voice_in_trunk_sip_registration.py`](voice_in_trunk_sip_registration.py) | End-to-end SIP registration flow: create with `enabled_sip_registration=True`, rename, disable by setting `host`, re-enable by toggling the flag. The SDK keeps the dependent fields (`host`, `port`, `use_did_in_ruri`) aligned with the server's validation rules automatically. |
| [`orders.py`](orders.py) | Lists orders, creates a DID order, and cancels it. |
| [`orders_available_dids.py`](orders_available_dids.py) | Orders a specific available DID using included DID group SKU. |
| [`orders_reservation_dids.py`](orders_reservation_dids.py) | Reserves a DID and then places an order from that reservation. |
Expand Down
81 changes: 81 additions & 0 deletions examples/voice_in_trunk_sip_registration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""End-to-end SIP registration flow on /voice_in_trunks (API 2026-04-16):
create with sip_registration enabled → rename → disable by setting `host`
→ re-enable by toggling the flag. Demonstrates how the SDK keeps the
dependent fields (`host`, `port`, `use_did_in_ruri`) aligned with the
server's validation rules. The sandbox trunk is left in place after the
script completes.

Usage: DIDWW_API_KEY=xxx python examples/voice_in_trunk_sip_registration.py
"""
import time
from client_factory import create_client
from didww.resources.configuration.sip import SipConfiguration
from didww.resources.voice_in_trunk import VoiceInTrunk
from didww.enums import CliFormat, Codec, TransportProtocol

client = create_client()

print("=== Python SDK — SIP registration flow ===")

# 1) Create with sip_registration enabled.
print("\n[1/4] Create with sip_registration enabled...")
sip = SipConfiguration()
sip.enabled_sip_registration = True
sip.use_did_in_ruri = True
sip.cnam_lookup = False
sip.codec_ids = [Codec.PCMU, Codec.PCMA]
sip.transport_protocol_id = TransportProtocol.UDP

trunk = VoiceInTrunk()
trunk.name = f"py-sip-registration-{int(time.time())}"
trunk.priority = 1
trunk.weight = 100
trunk.cli_format = CliFormat.E164
trunk.ringing_timeout = 30
trunk.configuration = sip

created = client.voice_in_trunks().create(trunk).data
trunk_id = created.id
print(f" id={trunk_id}")
print(f" incoming_auth_username={created.configuration.incoming_auth_username!r}")
print(f" incoming_auth_password={created.configuration.incoming_auth_password!r}")

# 2) Rename — single-field PATCH.
print("\n[2/4] Rename trunk...")
created.name = f"py-renamed-{int(time.time())}"
client.voice_in_trunks().update(created)
print(f" name={created.name}")

# 3) Disable sip_registration by setting `host`.
print("\n[3/4] Disable by setting host...")
disable = SipConfiguration()
disable.host = "203.0.113.10"
update = VoiceInTrunk()
update.id = trunk_id
update.configuration = disable
client.voice_in_trunks().update(update)
fresh = client.voice_in_trunks().find(trunk_id).data
print(f" enabled_sip_registration={fresh.configuration.enabled_sip_registration!r}")
print(f" use_did_in_ruri={fresh.configuration.use_did_in_ruri!r}")
print(f" host={fresh.configuration.host!r}")
print(f" incoming_auth_username={fresh.configuration.incoming_auth_username!r}")

# 4) Re-enable sip_registration. The SDK should send host=None / port=None on
# the wire so the server clears the values it had persisted.
print("\n[4/4] Re-enable by toggling enabled_sip_registration...")
re_enable = SipConfiguration()
re_enable.enabled_sip_registration = True
re_enable.use_did_in_ruri = True
update = VoiceInTrunk()
update.id = trunk_id
update.configuration = re_enable
try:
client.voice_in_trunks().update(update)
fresh = client.voice_in_trunks().find(trunk_id).data
print(f" enabled_sip_registration={fresh.configuration.enabled_sip_registration!r}")
print(f" host={fresh.configuration.host!r}")
print(f" incoming_auth_username={fresh.configuration.incoming_auth_username!r}")
print(f"\n=== PASS — trunk {trunk_id} left in sandbox ===")
except Exception as e:
print(f" ✗ FAIL: {e}")
print(f"\n=== FAIL at re-enable — trunk {trunk_id} left in sandbox ===")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "didww"
version = "3.0.0"
version = "3.1.0"
description = "Python SDK for DIDWW API v3"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
15 changes: 15 additions & 0 deletions src/didww/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ class DiversionRelayPolicy(str, Enum):
TEL = "tel"


class DiversionInjectMode(str, Enum):
"""Diversion header injection mode for SIP INVITE. (API 2026-04-16)"""
NONE = "none"
DID_NUMBER = "did_number"


class NetworkProtocolPriority(str, Enum):
"""SIP network protocol priority. (API 2026-04-16)"""
FORCE_IPV4 = "force_ipv4"
FORCE_IPV6 = "force_ipv6"
ANY = "any"
PREFER_IPV4 = "prefer_ipv4"
PREFER_IPV6 = "prefer_ipv6"


class StirShakenMode(str, Enum):
DISABLED = "disabled"
ORIGINAL = "original"
Expand Down
25 changes: 25 additions & 0 deletions src/didww/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Reusable mixins shared across resource hierarchies."""


class RedactsSensitiveAttributes:
"""Provides a `__repr__` that masks values of attribute keys listed in
``_sensitive_attrs`` so credentials never leak through default
``print()`` / logging / unhandled exception output.

The wire payload is unaffected — the host class's serializer (e.g.
``to_jsonapi``) still emits the real values.

Used by ``TrunkConfiguration`` and ``AuthenticationMethod``, which
both expose an ``_attributes`` dict and a ``_sensitive_attrs``
frozenset that subclasses extend.
"""

_sensitive_attrs = frozenset()

def __repr__(self):
parts = []
for key, value in self._attributes.items():
if key in self._sensitive_attrs and value is not None:
value = "[FILTERED]"
parts.append(f"{key}={value!r}")
return f"{self.__class__.__name__}({', '.join(parts)})"
9 changes: 8 additions & 1 deletion src/didww/resources/authentication_method.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
class AuthenticationMethod:
from didww.mixins import RedactsSensitiveAttributes


class AuthenticationMethod(RedactsSensitiveAttributes):
"""Base class for polymorphic VoiceOutTrunk authentication methods."""
_type = None
_type_map = {}
# Subclasses extend `_sensitive_attrs` (inherited from
# RedactsSensitiveAttributes) to mark credential-bearing keys; the
# mixin's `__repr__` masks their values with `[FILTERED]`.

def __init__(self, **kwargs):
self._attributes = kwargs
Expand Down Expand Up @@ -61,6 +67,7 @@ class IpOnlyAuthenticationMethod(AuthenticationMethod):

class CredentialsAndIpAuthenticationMethod(AuthenticationMethod):
_type = "credentials_and_ip"
_sensitive_attrs = frozenset({"username", "password"})

allowed_sip_ips = _plain("allowed_sip_ips")
tech_prefix = _plain("tech_prefix")
Expand Down
20 changes: 18 additions & 2 deletions src/didww/resources/configuration/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
class TrunkConfiguration:
from didww.mixins import RedactsSensitiveAttributes


class TrunkConfiguration(RedactsSensitiveAttributes):
_type = None
_type_map = {}
# Set of attribute keys that are deserialized from server responses
# but MUST NOT be sent in POST/PATCH request bodies. Subclasses should
# extend this set to mark server-generated read-only fields. The server
# rejects writes to these keys with 400 Param not allowed.
_read_only_attrs = frozenset()
# Subclasses extend `_sensitive_attrs` (inherited from
# RedactsSensitiveAttributes) to mark credential-bearing keys; the
# mixin's `__repr__` masks their values with `[FILTERED]`.

def __init__(self, attributes=None):
self._attributes = attributes or {}
Expand All @@ -12,9 +23,14 @@ def _set_attr(self, key, value):
self._attributes[key] = value

def to_jsonapi(self):
attrs = {
k: v
for k, v in self._attributes.items()
if k not in self._read_only_attrs
}
return {
"type": self._type,
"attributes": dict(self._attributes),
"attributes": attrs,
}

@classmethod
Expand Down
86 changes: 78 additions & 8 deletions src/didww/resources/configuration/sip.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from didww.resources.configuration.base import TrunkConfiguration
from didww.enums import (
Codec,
DiversionInjectMode,
DiversionRelayPolicy,
MediaEncryptionMode,
NetworkProtocolPriority,
ReroutingDisconnectCode,
RxDtmfFormat,
SstRefreshMethod,
Expand Down Expand Up @@ -36,14 +38,41 @@ def _enum_list(key, cls):

class SipConfiguration(TrunkConfiguration):
_type = "sip_configurations"
# Server-generated SIP registration credentials. The API rejects any
# write attempt with 400 Param not allowed, so they are deserialized
# from responses (readable via the property) but stripped from
# POST/PATCH request bodies. (API 2026-04-16)
_read_only_attrs = frozenset({
"incoming_auth_username",
"incoming_auth_password",
})
_sensitive_attrs = frozenset({
"auth_password",
"incoming_auth_username",
"incoming_auth_password",
})

username = _plain("username")
host = _plain("host")
port = _plain("port")
auth_enabled = _plain("auth_enabled")
resolve_ruri = _plain("resolve_ruri")
auth_user = _plain("auth_user")
auth_password = _plain("auth_password")

# `host` cascades dependent fields whose constraints are server-enforced
# (API 2026-04-16): setting host to a non-null value implies
# enabled_sip_registration = False and use_did_in_ruri = False.
@property
def host(self):
return self._attr("host")

@host.setter
def host(self, value):
if value is not None:
self._set_attr("enabled_sip_registration", False)
self._set_attr("use_did_in_ruri", False)
self._set_attr("host", value)

auth_from_user = _plain("auth_from_user")
auth_from_domain = _plain("auth_from_domain")
sst_enabled = _plain("sst_enabled")
Expand All @@ -59,16 +88,57 @@ class SipConfiguration(TrunkConfiguration):
max_30x_redirects = _plain("max_30x_redirects")
allowed_rtp_ips = _plain("allowed_rtp_ips")

# API 2026-04-16 SIP registration + protocol attributes.
#
# Server-side validation rules for `enabled_sip_registration`:
# * When True, the trunk's `host` and `port` must be left blank
# (server returns 422 otherwise).
# * When disabling sip registration on an existing trunk, the same
# PATCH must also set `host` to a non-blank value and
# `use_did_in_ruri` to False, or the server returns 422.
use_did_in_ruri = _plain("use_did_in_ruri")
cnam_lookup = _plain("cnam_lookup")

# `enabled_sip_registration` cascades dependent fields whose constraints
# are server-enforced (API 2026-04-16):
# * True -> host = None, port = None (always — a PATCH against an
# existing trunk that has host/port persisted on the
# server side MUST explicitly nullify them or the server
# rejects with 422).
# * False -> use_did_in_ruri = False (server requires it disabled
# whenever sip_registration is disabled).
@property
def enabled_sip_registration(self):
return self._attr("enabled_sip_registration")

@enabled_sip_registration.setter
def enabled_sip_registration(self, value):
if value is True:
self._set_attr("host", None)
self._set_attr("port", None)
elif value is False:
self._set_attr("use_did_in_ruri", False)
self._set_attr("enabled_sip_registration", value)


# Read-only: server-generated when SIP registration is enabled.
# Deserialized from responses but stripped on serialize via
# `_read_only_attrs` above (API rejects writes with 400 Param not allowed).
incoming_auth_username = property(lambda self: self._attr("incoming_auth_username"))
incoming_auth_password = property(lambda self: self._attr("incoming_auth_password"))

codec_ids = _enum_list("codec_ids", Codec)
rerouting_disconnect_code_ids = _enum_list("rerouting_disconnect_code_ids", ReroutingDisconnectCode)

transport_protocol_id = _enum("transport_protocol_id", TransportProtocol)
rx_dtmf_format_id = _enum("rx_dtmf_format_id", RxDtmfFormat)
tx_dtmf_format_id = _enum("tx_dtmf_format_id", TxDtmfFormat)
sst_refresh_method_id = _enum("sst_refresh_method_id", SstRefreshMethod)
media_encryption_mode = _enum("media_encryption_mode", MediaEncryptionMode)
stir_shaken_mode = _enum("stir_shaken_mode", StirShakenMode)
diversion_relay_policy = _enum("diversion_relay_policy", DiversionRelayPolicy)
transport_protocol_id = _enum("transport_protocol_id", TransportProtocol)
rx_dtmf_format_id = _enum("rx_dtmf_format_id", RxDtmfFormat)
tx_dtmf_format_id = _enum("tx_dtmf_format_id", TxDtmfFormat)
sst_refresh_method_id = _enum("sst_refresh_method_id", SstRefreshMethod)
media_encryption_mode = _enum("media_encryption_mode", MediaEncryptionMode)
stir_shaken_mode = _enum("stir_shaken_mode", StirShakenMode)
diversion_relay_policy = _enum("diversion_relay_policy", DiversionRelayPolicy)
diversion_inject_mode = _enum("diversion_inject_mode", DiversionInjectMode)
network_protocol_priority = _enum("network_protocol_priority", NetworkProtocolPriority)


TrunkConfiguration.register("sip_configurations", SipConfiguration)
Loading
Loading