At Amazon I worked on the public phone-number APIs for Amazon Connect: I designed, built, tested, and deployed them globally, in Java, to support Connect users provisioning numbers for their contact centers. A phone number looks like a string. In Connect it’s a cloud resource with an ARN, tags, IAM permissions, an asynchronous provisioning workflow behind it, and a quota rule that stops you from churning numbers. Everything in this post comes from the public contract — and the public contract is where the interesting decisions live.
Amazon Connect is AWS’s cloud contact center. Before any call can reach an agent, the customer needs a phone number to dial, so the very first thing every Connect deployment does is provision numbers. That lifecycle is managed by the family of public APIs I worked on: ClaimPhoneNumber, ImportPhoneNumber, DescribePhoneNumber, and UpdatePhoneNumber, plus siblings like SearchAvailablePhoneNumbers and ReleasePhoneNumber.
| API | Call | What it does |
|---|---|---|
| ClaimPhoneNumber | POST /phone-number/claim |
Provision an available number to an instance or traffic distribution group |
| ImportPhoneNumber | POST /phone-number/import |
Bring a number you already own in AWS End User Messaging into Connect |
| DescribePhoneNumber | GET /phone-number/{PhoneNumberId} |
Details plus the status of the last claim/import/update |
| UpdatePhoneNumber | PUT /phone-number/{PhoneNumberId} |
Re-point the number at a different instance or traffic distribution group |
Four small APIs, but the contract encodes more design than it looks like it does. This post walks through the decisions.
A phone number is a resource, not a string
The obvious way to model a phone number is as its E.164 string: +18005550100 in, +18005550100 out. The APIs deliberately don’t do that. ClaimPhoneNumber returns two identifiers:
{
"PhoneNumberId": "a1b2c3d4-...",
"PhoneNumberArn": "arn:aws:connect:us-east-1:123456789012:phone-number/a1b2c3d4-..."
}
and every other API in the family keys on PhoneNumberId or the ARN, never the number itself. The reason is that the string isn’t a stable identity. Numbers get released, sit in a cooldown pool, and get claimed by someone else later; the same digits can refer to different resources at different times, owned by different AWS accounts. The UUID names this claim of the number, not the digits.
Being a first-class resource buys everything AWS resources get for free: the ARN plugs into IAM policies, the Tags map (up to 50 entries, standard AWS constraints) plugs into cost allocation and access control, and the resource shows up like any other in audit tooling. None of that works on a bare string.
The 200 doesn’t mean it’s done
ClaimPhoneNumber returns HTTP 200 with an ID in well under a second. The number is not yours yet. Provisioning a real phone number touches systems well beyond the request path — telephony inventory, routing configuration — and can take much longer than any sane HTTP timeout. So the API is asynchronous by contract: the 200 means accepted, and the docs tell you explicitly to call DescribePhoneNumber to find out what happened. Status lives in the response as a proper state, with a message when things go wrong:
stateDiagram-v2
[*] --> IN_PROGRESS : ClaimPhoneNumber / ImportPhoneNumber / UpdatePhoneNumber (HTTP 200)
IN_PROGRESS --> CLAIMED : provisioning workflow succeeds
IN_PROGRESS --> FAILED : provisioning workflow fails (Message says why)
CLAIMED --> IN_PROGRESS : UpdatePhoneNumber
The part I’d point at in a design review: FAILED is a first-class state, not an HTTP error. By the time provisioning fails, the HTTP call is long over — there’s no request left to return a 500 on. Any API that accepts work asynchronously has to give failure somewhere durable to live, and here it lives in PhoneNumberStatus next to a Message explaining itself. If your async API can only report failure on the initial call, it can only report failures it finds in the first hundred milliseconds.
Idempotency, because retries are not optional
Every mutating API in the family takes a ClientToken, and if you don’t supply one the AWS SDK generates it for you. The mechanics: retry the call with the same token and you get the same operation back; reuse a token for a different operation and you get a 409 IdempotencyException.
For most APIs idempotency is good hygiene. For this one it’s load-bearing. The failure mode without it: a client claims a number, the response times out somewhere in the network, the client retries, and now it has claimed two phone numbers — real, scarce, carrier-leased resources that cost money monthly — while believing it has one. Timeouts are routine, every serious client wraps calls in retries, so the API has to make retries safe rather than hoping clients are careful. The docs link Amazon’s own essay on the pattern, Making retries safe with idempotent APIs, which is the best public write-up of why this matters.
One number, two kinds of owner
ClaimPhoneNumber and UpdatePhoneNumber both take either an InstanceId or a TargetArn — exactly one. A number can belong to a single Connect instance, or to a traffic distribution group, which spans two AWS Regions so that inbound calls survive a regional failover.
The traffic distribution group case leaks a genuinely interesting constraint into the contract. From the DescribePhoneNumber docs: in the Region where the group was created you may pass either the UUID or the full ARN, but in the group’s alternate Region you must pass the full ARN — a UUID gets you ResourceNotFoundException. The reason is visible if you look at what each identifier carries. A bare UUID is only resolvable where the resource’s home records live. The ARN embeds the Region and account, so it carries enough information to be resolved from anywhere. Cross-region resources make “which Region am I talking to?” part of your API’s semantics whether you plan for it or not; the contract is where that surfaces.
UpdatePhoneNumber moves the number, not the flow
UpdatePhoneNumber re-points a number at a different instance or traffic distribution group. The docs carry a warning box: the API switches only the number. The contact flow the number was attached to — the logic deciding what happens when someone calls — does not come along, and you must attach a flow in the target yourself.
That reads like a limitation. It’s a correct-by-design decision. A flow belongs to an instance; it references queues, prompts, and Lambda functions that exist in that instance. “Migrate the flow too” sounds helpful and is actually impossible to do correctly in the general case — the referenced resources don’t exist on the other side. The API does the one operation with unambiguous semantics and documents the seam, instead of doing a second operation badly. Small mutations with sharp edges you can see beat big mutations with soft edges you can’t.
The 180-day rule
Both claim and import carry an unusual paragraph: you can claim-and-release up to 200% of your phone number quota in a rolling 180-day window, and past that you’re blocked from claiming until 180 days after the oldest release — unless you open a support ticket.
This exists because the resource behind the API is not virtual. Phone numbers come from carrier inventory; they’re finite within a country and prefix, and a released number has to sit in cooldown before anyone can safely reuse it (the previous owner’s customers are still calling it). An API that allowed unlimited churn would let one account burn through a country’s available inventory. When your API provisions something physically scarce, the scarcity has to show up in the contract — the alternative is that it shows up as an outage of the “no numbers available” kind for everyone else.
Calling it from Java
The service itself is Java, and so is the natural client. The whole lifecycle, with the async contract handled honestly (production code would cap the polling and handle FAILED properly):
ConnectClient connect = ConnectClient.create();
ClaimPhoneNumberResponse claim = connect.claimPhoneNumber(r -> r
.instanceId(instanceId)
.phoneNumber("+18005550100") // from SearchAvailablePhoneNumbers
.phoneNumberDescription("Support line")
.clientToken(UUID.randomUUID().toString())); // retries now safe
// 200 means accepted, not done. Poll for the terminal state.
String id = claim.phoneNumberId();
ClaimedPhoneNumberSummary summary;
do {
Thread.sleep(1_000);
summary = connect.describePhoneNumber(r -> r.phoneNumberId(id))
.claimedPhoneNumberSummary();
} while (summary.phoneNumberStatus().status() == PhoneNumberWorkflowStatus.IN_PROGRESS);
if (summary.phoneNumberStatus().status() == PhoneNumberWorkflowStatus.FAILED) {
throw new IllegalStateException(summary.phoneNumberStatus().message());
}
What shipping a public AWS API teaches you
The contract is forever. Once a field, enum value, or error shape is public, someone’s production system depends on it. You can add; you can never change or remove. Every name in that response syntax was a one-way door, and it was reviewed like one.
The errors are the API too. IdempotencyException on token reuse, ResourceInUseException on a conflicting update, ThrottlingException at the rate limit — clients build retry and reconciliation logic against these specific shapes. An undocumented error is a bug even when the behavior behind it is right.
The warning boxes are the design document. Every “Important” callout in these docs — poll DescribePhoneNumber, the flow doesn’t migrate, use the full ARN in the alternate Region, the 180-day rule — marks a place where the system’s real constraints meet the API’s surface. Reading a mature API’s warning boxes is the fastest way I know to reverse-engineer the design pressures its builders were under. Now you know four of them from the inside.