What you'll learn: How to take one partner-facing Orders API from requirements to production on Apigee, in order: design and OpenAPI contract, proxy bundle, OAuth security, traffic limits and caching, one error contract with observability, tiered products and onboarding, environments and deployment, and a pipeline that promotes one revision. You finish with a readiness checklist, a lab plan and twelve interview questions.
The ten earlier lessons each took one layer apart. A real API needs all of them at once, in a sensible order, with matching names. This lesson is the dry run: one API built end to end with the course's conventions. It summarises and links instead of repeating, so keep the earlier lessons open. Every number is an assumption, labelled as such, and hostnames are illustrative.
The core idea
Design the contract first, then add one layer at a time, and test each layer by trying to break it. The build order below is the same order a reviewer will read the result.
| Step | You produce | Lesson |
|---|---|---|
| 1 Design | Requirements, OpenAPI spec, limits, SLOs | Design and products |
| 2 Proxy | Bundle, flows, route rule | Proxies |
| 3 Security | OAuth, threat protection, secrets | Security |
| 4 Traffic | Spike arrest, quota, cache, timeouts | Traffic |
| 5 Faults | One error shape, logging, alerts | Faults |
| 6 Products | Two tiers, a developer, an app | Design and products |
| 7 Environments | Per-environment config, deploy, rollback | Environments |
| 8 Pipeline | Lint, test, build once, promote | CI/CD |
Names used throughout: proxy orders-v1 at base path /orders/v1, a token proxy at /oauth, products partner-orders-basic and partner-orders-plus, target servers orders-a and orders-b, KVM orders-config, and the error body {"error":{"code","message","correlationId"}}.
How to read it: the solid path is the request. The dashed lines are things that never sit in the request path: deployment and telemetry.
Step 1: requirements and design
Write the decisions down before any XML exists. Everything marked (assumed) is an illustrative planning number to be replaced by measurement.
| Area | Decision |
|---|---|
| Consumers | Partner logistics systems calling server to server; three at launch (assumed) |
| Operations | GET /orders (cursor pagination), GET /orders/{orderId}, POST /orders with a required Idempotency-Key |
| Versioning | Major version in the base path, one proxy and one product set per major version |
| Security | OAuth 2.0 client credentials; the product is the allow-list of operations |
| Tiers | Basic is read-only; Plus adds POST and higher quotas |
| Capacity (assumed) | Backend sustains about 60 requests per second; global spike arrest 50 per second; per-client 10 per second |
| SLOs (assumed) | 99.9 percent availability and p95 latency under 600 ms over 30 days |
| Errors | One JSON shape for every failure, including gateway-raised ones |
The key parts of the OpenAPI contract (an excerpt: responses and most schemas are in the design lesson). Note that servers lists the public URL consumers call, not the backend: the backend is a target server in each environment.
openapi: 3.0.3
info: { title: Orders API, version: 1.0.0 }
servers: [{ url: https://partner.example.com/orders/v1 }] # illustrative public host
security: [{ partnerOAuth: [] }]
paths:
/orders:
get: { operationId: listOrders, summary: "List orders" } # query: limit (max 100), cursor, status
post:
operationId: createOrder
parameters: [{ name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 64 } }]
/orders/{orderId}:
get: { operationId: getOrder, summary: "Fetch one order" }
components:
securitySchemes:
partnerOAuth:
type: oauth2
flows:
clientCredentials: { tokenUrl: https://partner.example.com/oauth/token, scopes: {} }
schemas:
Error: # the one contract for every 4xx and 5xx
type: object
required: [error]
properties:
error: { type: object, required: [code, message], properties: { code: { type: string }, message: { type: string }, correlationId: { type: string } } }
The spec carries the same correlationId field the fault-handling lesson returns, so the spec, the gateway and the logs agree. Pick one name per API and put it in the spec.
Why OAuth client credentials rather than an API key: callers are servers, the data is customer orders, and OAuth gives short token lifetimes and per-app revocation. The price is a token endpoint and client-side token caching. Apigee caches tokens, so a revoked token can still pass for up to about three minutes; short lifetimes (30 minutes, assumed) are the real lever. A key over TLS stays reasonable for low-risk reads.
Step 2: build the proxy bundle
One repository, two bundles, no environment values inside either.
orders-platform/
openapi/orders-v1.yaml
proxies/
oauth/apiproxy/ # token endpoint: SA-ProtectTokenEndpoint, OAuth-GenerateAccessToken
orders-v1/apiproxy/
orders-v1.xml # <APIProxy name="orders-v1"/>
proxies/default.xml
targets/default.xml
policies/ # SA-*, OAuth-*, Q-*, RF-*, JTP-*, RC-*, KVM-*, AM-*, ML-*
terraform/ # target servers and KVMs per environment
tests/integration/ tests/smoke/
.github/workflows/orders.yml
The order of execution for a call, which the XML below encodes:
- PreFlow: global spike arrest, then token verification (identity and product allow-list), per-client spike arrest, then quota. Cheapest rejection first, quota only after identity.
- Conditional flow: one per operation, and only the first match runs.
GetOrderdoes the cache lookup,CreateOrdervalidates. - Route rule, then the target PreFlow: backend credential from the KVM, identity headers rewritten.
- Response flows: cache populate, then the
PostFlowcorrelation header. - PostClientFlow: the access log line.
apiproxy/proxies/default.xml, assembled:
<ProxyEndpoint name="default">
<PreFlow name="PreFlow">
<Request>
<Step><Name>SA-ProtectBackend</Name></Step>
<Step><Name>OAuth-VerifyAccessToken</Name></Step>
<Step><Name>SA-PerClient</Name></Step>
<Step><Name>Q-PlanFromProduct</Name></Step>
</Request>
<Response/>
</PreFlow>
<Flows>
<Flow name="ListOrders">
<Condition>(proxy.pathsuffix MatchesPath "/orders") and (request.verb = "GET")</Condition>
<Request/><Response/>
</Flow>
<Flow name="GetOrder">
<Condition>(proxy.pathsuffix MatchesPath "/orders/*") and (request.verb = "GET")</Condition>
<Request><Step><Name>RC-GetOrder</Name></Step></Request>
<Response><Step><Name>RC-GetOrder</Name></Step></Response>
</Flow>
<Flow name="CreateOrder">
<Condition>(proxy.pathsuffix MatchesPath "/orders") and (request.verb = "POST")</Condition>
<Request>
<Step><Name>RF-MissingIdempotencyKey</Name><Condition>request.header.idempotency-key = null</Condition></Step>
<Step><Name>RF-UnsupportedMediaType</Name><Condition>not (request.header.content-type Matches "application/json*")</Condition></Step>
<Step><Name>JTP-LimitBody</Name></Step>
</Request>
<Response/>
</Flow>
</Flows>
<PostFlow name="PostFlow">
<Request/>
<Response><Step><Name>AM-Correlation-Header</Name></Step></Response>
</PostFlow>
<PostClientFlow name="PostClientFlow">
<Response><Step><Name>ML-Access-Log</Name></Step></Response>
</PostClientFlow>
<FaultRules>
<FaultRule name="unauthenticated">
<Step><Name>AM-Error-401</Name></Step>
<Step><Name>AM-Correlation-Header</Name></Step>
<Condition>(fault.name = "InvalidAccessToken") or (fault.name = "invalid_access_token") or (fault.name = "access_token_expired") or (fault.name = "access_token_not_approved") or (fault.name = "InvalidAPICallAsNoApiProductMatchFound")</Condition>
</FaultRule>
<FaultRule name="rate-limited">
<Step><Name>AM-Error-429</Name></Step>
<Step><Name>AM-Correlation-Header</Name></Step>
<Condition>(fault.name = "SpikeArrestViolation") or (fault.name = "QuotaViolation")</Condition>
</FaultRule>
<FaultRule name="client-raised">
<Step><Name>AM-Correlation-Header</Name></Step>
<Condition>fault.name = "RaiseFault"</Condition>
</FaultRule>
</FaultRules>
<DefaultFaultRule name="generic-error">
<Step><Name>AM-Error-500</Name></Step>
<Step><Name>AM-Correlation-Header</Name></Step>
</DefaultFaultRule>
<HTTPProxyConnection>
<BasePath>/orders/v1</BasePath>
<Properties><Property name="api.timeout">10000</Property></Properties>
</HTTPProxyConnection>
<RouteRule name="default"><TargetEndpoint>default</TargetEndpoint></RouteRule>
</ProxyEndpoint>
The OAuth fault.name values come from the OAuthV2 reference, which does not document the name for a missing token, so confirm every value in a Debug session and extend the condition. The DefaultFaultRule keeps an unmatched fault in the contract shape, and a negative test expecting 401 catches a rule that is too narrow. Because VerifyAccessToken checks that the product covers the proxy, path and method, an unknown path or a DELETE is rejected there (a 401 per the reference), so no catch-all flow is needed.
apiproxy/targets/default.xml, assembled:
<TargetEndpoint name="default">
<PreFlow name="PreFlow">
<Request>
<Step><Name>KVM-GetBackendToken</Name></Step>
<Step><Name>AM-BackendAuth</Name></Step>
</Request>
<Response/>
</PreFlow>
<FaultRules>
<FaultRule name="backend-timeout">
<Step><Name>AM-Error-504</Name></Step>
<Step><Name>AM-Correlation-Header</Name></Step>
<Condition>fault.name = "GatewayTimeout"</Condition>
</FaultRule>
<FaultRule name="backend-unavailable">
<Step><Name>AM-Error-503</Name></Step>
<Step><Name>AM-Correlation-Header</Name></Step>
<Condition>(fault.name = "ServiceUnavailable") or (fault.name = "NoActiveTargets") or (fault.name = "ErrorResponseCode")</Condition>
</FaultRule>
</FaultRules>
<DefaultFaultRule name="generic-error">
<Step><Name>AM-Error-500</Name></Step>
<Step><Name>AM-Correlation-Header</Name></Step>
</DefaultFaultRule>
<HTTPTargetConnection>
<Properties>
<Property name="connect.timeout.millis">2000</Property>
<Property name="io.timeout.millis">3000</Property>
</Properties>
<LoadBalancer>
<Algorithm>RoundRobin</Algorithm>
<Server name="orders-a"/>
<Server name="orders-b"/>
<MaxFailures>3</MaxFailures>
<ServerUnhealthyResponse><ResponseCode>503</ResponseCode></ServerUnhealthyResponse>
</LoadBalancer>
<HealthMonitor>
<IsEnabled>true</IsEnabled>
<IntervalInSec>5</IntervalInSec>
<HTTPMonitor>
<Request><Verb>GET</Verb><Path>/healthz</Path></Request>
<SuccessResponse><ResponseCode>200</ResponseCode></SuccessResponse>
</HTTPMonitor>
</HealthMonitor>
</HTTPTargetConnection>
</TargetEndpoint>
The sequence through the finished proxy, including the cache path:
How to read it: every rejection happens before the backend is touched, and a cache hit skips the target flow entirely.
Step 3: secure it
Authentication. The token proxy at /oauth issues client-credentials tokens (30-minute ExpiresIn, which is in milliseconds: 1800000) behind its own 30pm spike arrest, exactly as in the security lesson. The Orders proxy verifies them:
<OAuthV2 name="OAuth-VerifyAccessToken" continueOnError="false" enabled="true">
<Operation>VerifyAccessToken</Operation>
</OAuthV2>
After a successful verification the reference lists unprefixed flow variables such as client_id, developer.app.name and apiproduct.name. That differs from the verifyapikey.<policy>. prefix used in key-based examples, so every Identifier and header template in this bundle uses the unprefixed names. Confirm them in a Debug session.