Skip to content

    Apigee

    Capstone: Design, Build and Ship an Orders API on Apigee

    Build a partner Orders API on Apigee from spec to production: OAuth, tiered products, quotas, caching, an error contract, CI/CD and a readiness review.

    By Published 15 min read

    Share

    This lesson is part of Apigee API Management: Beginner to Advanced. Enroll for free, then sign in, to read it in full.

    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"}}.

    End to end: a partner calls the Orders API through Apigee; a pipeline ships it; Google Cloud observes itPartner ERP system to Google Cloud load balancer (HTTPS); Google Cloud load balancer to Apigee token proxy (get token); Google Cloud load balancer to Apigee orders-v1 (bearer token); Apigee orders-v1 to Orders service (southbound TLS); GitHub Actions to Apigee orders-v1 (deploys revision); Apigee orders-v1 to Cloud Logging and Monitoring (logs, metrics)APIGEE RUNTIMEPartner ERP system: server to serverPartner ERPsystemserver to serverGoogle Cloud load balancer: northbound entryGoogle Cloudload balancernorthbound entryApigee token proxy: /oauth/tokenApigee tokenproxy/oauth/tokenApigee orders-v1: /orders/v1Apigeeorders-v1/orders/v1Orders service: two target serversOrders servicetwo targetserversGitHub Actions: lint, test, deployGitHub Actionslint, test,deployCloud Logging and Monitoring: Google CloudCloud Loggingand MonitoringGoogle CloudHTTPSget tokenbearer tokensouthbound TLSdeploys revisionlogs, metrics

    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:

    1. 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.
    2. Conditional flow: one per operation, and only the first match runs. GetOrder does the cache lookup, CreateOrder validates.
    3. Route rule, then the target PreFlow: backend credential from the KVM, identity headers rewritten.
    4. Response flows: cache populate, then the PostFlow correlation header.
    5. 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:

    sequenceDiagram participant P as Partner system participant G as Apigee orders-v1 participant K as Cache participant B as Orders service P->>G: GET /orders/v1/orders/1001 with bearer token G->>G: SpikeArrest, VerifyAccessToken, per-client SpikeArrest, Quota G->>K: RC-GetOrder lookup (uri plus client_id) alt hit K-->>G: cached 200 G-->>P: 200 with X-Correlation-Id else miss G->>G: KVM backend token, AM-BackendAuth G->>B: GET /orders/1001 via orders-a or orders-b B-->>G: 200 order G->>K: populate (30 s, assumed) G-->>P: 200 with X-Correlation-Id end G--)G: PostClientFlow access log

    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.

    Threat protection. Writes get structural limits. The numbers are assumed; derive real ones from the largest legitimate order. The policy runs only for application/json, which is why RF-UnsupportedMediaType rejects any other content type first.

    <JSONThreatProtection name="JTP-LimitBody" continueOnError="false" enabled="true">
      <Source>request</Source>
      <ArrayElementCount>100</ArrayElementCount>
      <ContainerDepth>4</ContainerDepth>
      <ObjectEntryCount>20</ObjectEntryCount>
      <ObjectEntryNameLength>64</ObjectEntryNameLength>
      <StringValueLength>200</StringValueLength>
    </JSONThreatProtection>

    CORS is deliberately omitted: callers are servers, and a client secret cannot live in a browser. OAuth scopes are also skipped, since the product already restricts operations by path and method.

    Backend credentials and secrets. The backend token lives in an environment-scoped KVM and is read into a private. variable so Debug never shows it. The consumer's token is replaced before the call, and identity headers are removed then set, so a caller cannot forge them:

    <KeyValueMapOperations name="KVM-GetBackendToken" mapIdentifier="orders-config">
      <Scope>environment</Scope>
      <Get assignTo="private.backend.token"><Key><Parameter>backend-token</Parameter></Key></Get>
    </KeyValueMapOperations>
    <AssignMessage name="AM-BackendAuth">
      <Remove><Headers>
        <Header name="Authorization"/><Header name="x-client-id"/><Header name="x-client-app"/>
      </Headers></Remove>
      <Set><Headers>
        <Header name="Authorization">Bearer {private.backend.token}</Header>
        <Header name="x-client-id">{client_id}</Header>
        <Header name="x-client-app">{developer.app.name}</Header>
      </Headers></Set>
      <IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables>
      <AssignTo>request</AssignTo>
    </AssignMessage>

    IgnoreUnresolvedVariables is false on purpose: a missing identity fails the call instead of reaching the backend anonymous. Southbound TLS is enforced on the target servers (step 7). The backend must still decide whether this app may see this order. A KVM change can take up to the cache expiry (default 300 seconds) to reach traffic.

    Step 4: traffic and performance

    Three different jobs, three policies, plus timeouts and balancing already in the target.

    <SpikeArrest name="SA-ProtectBackend" continueOnError="false" enabled="true">
      <Rate>50ps</Rate>
      <UseEffectiveCount>true</UseEffectiveCount>
    </SpikeArrest>

    SA-PerClient is the same policy with <Identifier ref="client_id"/> and <Rate>10ps</Rate> (assumed). The plan allowance reads from the product the token belongs to:

    <Quota name="Q-PlanFromProduct" continueOnError="false" enabled="true">
      <Identifier ref="client_id"/>
      <Distributed>true</Distributed>
      <Synchronous>false</Synchronous>
      <UseQuotaConfigInAPIProduct stepName="OAuth-VerifyAccessToken">
        <DefaultConfig><Allow>100</Allow><Interval>1</Interval><TimeUnit>hour</TimeUnit></DefaultConfig>
      </UseQuotaConfigInAPIProduct>
    </Quota>

    The reference confirms stepName may name an OAuthV2 policy; the default config is a small fallback (assumed). Counters are per product operation. With asynchronous sync a few calls over the limit can slip through; if billing needs exactness, set Synchronous to true and accept lower throughput.

    Caching only where it is safe. GET /orders/{orderId} is cached; the paged list is not, because cursors and fresh orders make hits rare and stale pages confusing. The response depends on which partner asks, so the caller's identity is part of the key, the opposite of the "no API key in the key" rule for shared public data:

    <ResponseCache name="RC-GetOrder" continueOnError="false" enabled="true">
      <CacheKey>
        <Prefix>orders-get</Prefix>
        <KeyFragment ref="request.uri"/>
        <KeyFragment ref="client_id"/>
      </CacheKey>
      <ExpirySettings><TimeoutInSeconds>30</TimeoutInSeconds></ExpirySettings>
      <SkipCachePopulation>response.status.code != 200</SkipCachePopulation>
    </ResponseCache>

    A 30-second TTL (assumed) means a partner can see a status up to 30 seconds old; agree that with the business. It sits in the conditional flow, so it runs after authentication.

    Timeouts, balancing and retries are in the target above: a failing server leaves rotation and returns when healthy. A retry after the listed 503 is safe for POST only because the backend honours Idempotency-Key. Keep api.timeout (10 seconds, assumed) below the partner's client timeout, or partners give up before Apigee can answer cleanly.

    Step 5: faults and observability

    One error shape for every failure. The policy pattern is identical for 401, 500, 503 and 504; here is the 429 one, which also sets Retry-After:

    <AssignMessage name="AM-Error-429">
      <Set>
        <Headers><Header name="Retry-After">1</Header></Headers>
        <Payload contentType="application/json" variablePrefix="@" variableSuffix="#">{"error":{"code":"RATE_LIMITED","message":"Too many requests. Slow down and retry.","correlationId":"@messageid#"}}</Payload>
        <StatusCode>429</StatusCode>
        <ReasonPhrase>Too Many Requests</ReasonPhrase>
      </Set>
      <IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
    </AssignMessage>

    The RF-* RaiseFault policies and AM-Correlation-Header follow the templates in the fault-handling lesson. A constant one second suits the spike limit; for a quota rejection, derive the wait from ratelimit.Q-PlanFromProduct.expiry.time. This body uses the same contract as every other error the API returns, so partners see one shape. Codes: UNAUTHENTICATED 401, MISSING_IDEMPOTENCY_KEY and UNSUPPORTED_MEDIA_TYPE 400, RATE_LIMITED 429, BACKEND_UNAVAILABLE 503, BACKEND_TIMEOUT 504, INTERNAL_ERROR 500.

    flowchart TD R["Request in the proxy"] --> S{"Spike limit or quota exceeded?"} S -->|yes| E429["FaultRule rate-limited: 429 RATE_LIMITED, Retry-After"] S -->|no| A{"Token invalid, expired or product mismatch?"} A -->|yes| E401["FaultRule unauthenticated: 401 UNAUTHENTICATED"] A -->|no| V{"POST body or headers invalid?"} V -->|yes| E400["RaiseFault: 400 with a specific code"] V -->|no| T{"Backend failed or timed out?"} T -->|"503 or no active target"| E503["Target FaultRule: 503 BACKEND_UNAVAILABLE"] T -->|timeout| E504["Target FaultRule: 504 BACKEND_TIMEOUT"] T -->|"another fault"| E500["DefaultFaultRule: 500 INTERNAL_ERROR"] T -->|no| OK["200 or 201 from the backend"] E429 --> H["Correlation header added, access log line written"] E401 --> H E400 --> H E503 --> H E504 --> H E500 --> H OK --> H

    Whichever branch fires, the partner gets the same JSON shape with the id that is in the log.

    Logging. One MessageLogging policy in the PostClientFlow covers success and failure without adding latency:

    <MessageLogging name="ML-Access-Log">
      <CloudLogging>
        <LogName>projects/{organization.name}/logs/orders-api-access</LogName>
        <Message contentType="text/plain">correlation_id={messageid} client_id={client_id} verb={request.verb} path={request.path} status={message.status.code} fault={fault.name}</Message>
      </CloudLogging>
    </MessageLogging>

    Response-side variables can be unavailable in the PostClientFlow after an error, so check in a trace which fields are populated. Never log tokens or bodies.

    Dashboards, alerts and SLO checks. The built-in Analytics dashboards (API Proxy Performance, Error Code Analysis, Latency Analysis, Cache Performance, Target Performance) cover trends. Cloud Monitoring alerts cover the SLOs (assumed 99.9 percent, p95 under 600 ms):

    • An error-ratio alert on proxy/response_count filtered by response_code, as a share of all responses. A 99.9 percent target leaves about 43 minutes of outage budget per 30 days.
    • A p95 alert on the proxy/latencies distribution.
    • A watch on 429 counts per app and the cache hit ratio, since a silent limit looks like an outage. Filter by fault_source to split proxy from target failures. Treat Apigee's sample thresholds as starting points, and expect alerts to lag by minutes.

    Step 6: products, developers and apps

    Create the two tiers. Quotas sit on each operation (assumed values), and the Plus tier adds POST. The proxy and target must already be deployed, since product creation can fail otherwise. Setup assumes ORG, a gcloud token and jq.

    export TOKEN=$(gcloud auth print-access-token)
    export API=https://apigee.googleapis.com/v1/organizations/$ORG

    product-basic.json:

    {
      "name": "partner-orders-basic",
      "displayName": "Partner Orders Basic (read only)",
      "approvalType": "auto",
      "environments": ["test", "prod"],
      "operationGroup": {
        "operationConfigs": [
          { "apiSource": "orders-v1", "operations": [{ "resource": "/orders", "methods": ["GET"] }],
            "quota": { "limit": "600", "interval": "1", "timeUnit": "hour" } },
          { "apiSource": "orders-v1", "operations": [{ "resource": "/orders/*", "methods": ["GET"] }],
            "quota": { "limit": "1200", "interval": "1", "timeUnit": "hour" } }
        ]
      }
    }
    Operation Basic (assumed) Plus (assumed)
    GET /orders 600 per hour 3,000 per hour
    GET /orders/* 1,200 per hour 6,000 per hour
    POST /orders not included 600 per hour

    Create partner-orders-plus from the same shape with the Plus column and "approvalType": "manual", so a person vets every write-capable partner. Then onboard:

    curl -s -X POST "$API/apiproducts" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @product-basic.json
    curl -s -X POST "$API/apiproducts" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @product-plus.json
    
    curl -s -X POST "$API/developers" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d '{"email":"dev@partner.example","firstName":"Dana","lastName":"Partner","userName":"dana"}'
    
    curl -s -X POST "$API/developers/dev@partner.example/apps" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d '{"name":"partner-erp-app","apiProducts":["partner-orders-basic"]}' | tee app.json

    Exchange the credentials for a token and call the API (hostname illustrative):

    HOST=partner-test.example.com
    KEY=$(jq -r '.credentials[0].consumerKey' app.json); SECRET=$(jq -r '.credentials[0].consumerSecret' app.json)
    ACCESS=$(curl -s -X POST "https://$HOST/oauth/token" -u "$KEY:$SECRET" -d grant_type=client_credentials | jq -r .access_token)
    curl -i -H "Authorization: Bearer $ACCESS" "https://$HOST/orders/v1/orders?limit=5"

    How to choose the tier for a new partner:

    flowchart TD A{"Must the partner create orders?"} -->|yes| P["Plus product, manual approval after contract checks"] A -->|no| B{"Do reads stay within the Basic quota?"} B -->|yes| BA["Basic product"] B -->|no| C{"Does the sustained rate fit under the per-client spike limit and the backend capacity?"} C -->|yes| P C -->|no| D["Do not raise limits blindly: load test, offer batch reads or change the design, then define a new product"]

    Step 7: environments and deployment

    The same bundle runs everywhere. What differs is outside it: target servers orders-a and orders-b and the orders-config KVM, created in each environment.

    Promotion path: one revision moves through dev, test and prod; each environment holds its own backend configGitHub Actions to Apigee dev environment (revision N); Apigee dev environment to Apigee test environment (same N); Apigee test environment to Apigee prod environment (same N); Apigee dev environment to Mock backend; Apigee test environment to Staging orders; Apigee prod environment to Production ordersGROUP PARTNER-NONPRODGROUP PARTNER-PRODGitHub Actions: builds onceGitHub Actionsbuilds onceApigee dev environment: auto deployApigee devenvironmentauto deployApigee test environment: same revisionApigee testenvironmentsame revisionApigee prod environment: after approvalApigee prodenvironmentafter approvalMock backend: two serversMock backendtwo serversStaging orders: two serversStaging orderstwo serversProduction orders: two serversProductionorderstwo serversrevision Nsame Nsame N

    Hostnames belong to the environment groups, the revision never changes on the way up, and only the target servers differ. An evaluation organization allows two environments at most, so run the lab with test and prod.

    Create the per-environment configuration (repeat per environment with its own hosts; BACKEND_TOKEN comes from your secret store, never from git):

    create_target() {   # $1 environment, $2 name, $3 host
      curl -sS -X POST "$API/environments/$1/targetservers" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
        -d "{\"name\":\"$2\",\"host\":\"$3\",\"protocol\":\"http\",\"port\":443,\"isEnabled\":true,\"sSLInfo\":{\"enabled\":true,\"enforce\":true}}"
    }
    create_target test orders-a orders-a.test.internal.example.com
    create_target test orders-b orders-b.test.internal.example.com
    
    curl -sS -X POST "$API/environments/test/keyvaluemaps" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d '{"name":"orders-config","encrypted":true}'
    curl -sS -X POST "$API/environments/test/keyvaluemaps/orders-config/entries" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d "{\"name\":\"backend-token\",\"value\":\"$BACKEND_TOKEN\"}"

    Build once, deploy to the lower environment, then promote the same revision (the token proxy ships the same way):

    (cd proxies/orders-v1 && zip -r -X ../../dist/orders-v1.zip apiproxy)
    apigeecli apis create bundle -n orders-v1 -p dist/orders-v1.zip -e test -o "$ORG" -t "$TOKEN" --ovr --wait
    apigeecli apis listdeploy -n orders-v1 -o "$ORG" -t "$TOKEN"                      # note the revision, say 7
    apigeecli apis deploy -n orders-v1 -v 7 -e prod -o "$ORG" -t "$TOKEN" --ovr --wait

    Verify with curl through the environment's own hostname. Each negative test must show the contract body, not an Apigee default:

    Call Expected
    GET /orders/v1/orders with no token 401, JSON with UNAUTHENTICATED
    POST with a Basic-tier token 401 (product allow-list), contract body
    POST without Idempotency-Key 400, MISSING_IDEMPOTENCY_KEY
    GET /orders/1001 twice within 30 seconds second call is a cache hit (confirm in Debug)
    A burst above the per-client rate some 429 with Retry-After
    Backend stopped 503 or 504 with correlationId, never the backend page

    Roll back by redeploying the previous revision (undeploying alone does not restore it):

    gcloud apigee apis deploy 6 --api=orders-v1 --environment=prod --organization="$ORG" --override

    Step 8: the CI/CD pipeline

    The shape is the CI/CD lesson's. This API has no JavaScript callouts, so there is no unit-test stage; lint and integration tests carry the weight.

    flowchart LR PR["Pull request"] --> L["apigeelint, spec lint"] L --> B["Zip the bundle once, checksum"] B --> D["Merge: import, deploy to dev, --wait"] D --> I["Integration tests: negative cases first"] I --> G{"Reviewer approves production"} G --> Q["Record the live revision, deploy the same revision"] Q --> S["Read-only smoke tests"] S -->|fail| R["Redeploy the recorded revision"] S -->|pass| Done["Release complete"]
    # Outline only: the CI/CD lesson has the full workflow, federated auth and rollback steps.
    on: { pull_request: {}, push: { branches: [main] } }
    jobs:
      verify:       { runs-on: ubuntu-latest }              # npx apigeelint -s proxies/orders-v1/apiproxy --profile apigeex --maxWarnings 0
      build:        { needs: verify }                       # zip once, upload the artifact
      deploy-dev:   { needs: build, environment: dev }      # import, apigeecli apis deploy --ovr --wait, newman tests
      promote-prod: { needs: deploy-dev, environment: production }   # required reviewers; same revision, smoke test, rollback

    Step 9: production readiness review

    Walk this list before launch; each line needs evidence (a policy, a test, a dashboard), not an opinion.

    • Design: one spec, proxy and product set per version; no business rules in the proxy; a sunset plan exists.
    • Security: every path passes verification (try an unknown path); products list proxies, operations and environments; secrets only in encrypted KVMs behind private. variables; the backend authorises per object; southbound TLS enforced; token endpoint rate limited.
    • Performance: limits tested above their values; timeouts from measured latency; two or more target servers with MaxFailures above zero; cache keys reviewed; load test done outside production.
    • Operations: one error shape proven by negative tests; correlation id returned and logged; alerts with an owner and a runbook; Debug masks configured.
    • Delivery: all in git; the pipeline is the only path to production; same revision promoted; rollback rehearsed; no long-lived CI keys.

    How to practise this yourself

    Use an evaluation organization (free for 60 days, learning only, no real data). The hosted mock target does not implement /orders, so the lab makes flagged, lab-only edits: point both target servers at mocktarget.apigee.net, add <Path>/echo</Path> in the HTTPTargetConnection (Path applies only with a load balancer) and set the health monitor path to /json. Confirm in Debug where /orders/v1/orders/1001 lands on the mock; if it returns a 404, set target.copy.pathsuffix to false as in the proxies lesson.

    # Do Expected result
    1 Write the OpenAPI spec and the assumptions table A reviewable contract and written numbers
    2 Deploy the bundle (pass-through first) to test A 200 through the group hostname
    3 Add the token proxy, OAuth verification, then the product No token gives 401; a valid token gives 200
    4 Add spike arrest and quota with tiny limits 429 after the limit, with Retry-After
    5 Add RC-GetOrder Second identical GET is a hit in Debug
    6 Add fault rules; stop one target server Contract body on failures; traffic continues
    7 Create both products, one developer, one app Credential issued; Basic cannot POST
    8 Promote the same revision to prod, then roll back The previous revision serves again
    9 Add the pipeline; fail the smoke test on purpose Automatic rollback to the recorded revision
    10 Run the readiness list against your build A written list of gaps

    How to extend it (next steps)

    • Next step: an AI endpoint. Add a summaries flow that calls an LLM with token quotas and the key in a KVM: Apigee as an AI Gateway.
    • Next step: multi-region. Create an instance in a second region and attach the existing environments to it (environments lesson).

    Interview-style questions

    1. Why OAuth client credentials instead of an API key here? Callers are servers handling customer data; tokens are short lived and revocable per app. A key is a static secret.
    2. Where does a partner's quota live, and what makes it apply? On the product operations; it applies only because a Quota policy with UseQuotaConfigInAPIProduct runs after the verification policy it names.
    3. Why spike arrest first and quota after authentication? Spike arrest is cheap and needs no identity; quota needs the caller's identity.
    4. What if a product lists no proxies? Every app holding it can reach every proxy in the organization.
    5. Version versus revision? A version is the consumer contract (the base path); a revision is an immutable snapshot, the unit of deployment and rollback.
    6. How do you keep one bundle across environments? Name target servers and KVMs in the bundle, define values per environment, promote unchanged.
    7. Why is client_id in the cache key? The response depends on who asks; without it one partner could receive another's cached order.
    8. How does a backend 503 become your contract body? A target FaultRule matches fault.name, AssignMessage sets the JSON and status, and a DefaultFaultRule catches the rest. Never match message text.
    9. Why log in the PostClientFlow? It runs after the response on success and error, so it adds no latency.
    10. A revoked token still works. Why? Apigee caches tokens, so revocation can take about three minutes; use short lifetimes.
    11. Is retrying a failed POST safe? Only if the backend deduplicates by Idempotency-Key; the gateway does not.
    12. What does the gateway not do? Object-level authorisation. It authenticates and shapes traffic; the service checks that this app may read this order.

    In practice: a launch review of the Orders API

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A team built orders-v1 as a demo: a shared API key and a hard-coded backend URL. Two weeks before three partners go live, a launch review asks for evidence on security, limits, errors and rollback.

    Starting architecture

    Demo build: key check only, backend URL in the bundle, deploys by handPartner system to Apigee proxy (shared key); Apigee proxy to Orders service (raw errors)Partner systemPartner systemApigee proxy: key check onlyApigee proxykey check onlyOrders service: URL in the bundleOrders serviceURL in the bundleshared keyraw errors

    What changes

    1. Write the contract; split the shared key into per-partner apps and two products.
    2. Add OAuth, spike arrest, per-client limits and the product-driven quota.
    3. Add the error layer, the access log and the alerts.
    4. Move the backend into target servers and a KVM; add the pipeline with approval and rollback.
    5. Run the negative-test table, then the readiness list.

    Result

    The review finds evidence for each line: a rejected call per failure type, one rehearsed rollback, alerts with an owner. Open items are recorded, such as the cache TTL awaiting business sign-off. A misbehaving partner hits its own limits, and an incident starts from a correlation id.

    Architect's notes

    • Choose the simplest control you can defend. For low-risk data a key plus quota is simpler; the token proxy is one more component to operate.
    • Limits come from measurement. Replace every assumed number after a load test that includes a backend failure.
    • Own the retry story. Client, gateway and backend retries multiply load; decide who retries.
    • Cost. Several policies here are Extensible and may carry licensing implications; check your licence.

    Common mistakes: the ten findings that fail audits and incidents

    1. A product that protects nothing. No verification policy, or no proxies listed. Verify in the PreFlow and scope products.
    2. Quota that never runs. Values on the product without a Quota policy, or Quota before authentication. Test with a tiny limit.
    3. Wrong variable names. verifyapikey.* after OAuth verification. Use the unprefixed names and confirm in Debug.
    4. Raw errors reaching partners. No DefaultFaultRule, message-text matching, stack traces. Test every failure type.
    5. Secrets in the bundle or in Debug. Use encrypted KVMs and private. variables.
    6. Caching the wrong thing. Per-partner data under a shared key, or errors and writes.
    7. Defaults left in place. MaxFailures 0, a 55-second I/O timeout, non-distributed quota.
    8. One bundle per environment. Tests stop predicting production.
    9. No rollback rehearsal. Undeploy is not rollback; redeploy the previous revision.
    10. No alert owner. Assign an owner and a runbook.

    Key takeaways

    • Design the contract, limits and SLOs first; label every assumed number.
    • One proxy per major version; the product is the allow-list and the offer.
    • Order the PreFlow: spike arrest, authenticate, per-client limit, quota, then per-operation flows.
    • One error shape for every failure, one correlation id in the response and the log.
    • Environment differences live in target servers and KVMs; build once, promote the same revision, rehearse rollback.
    • Confirm every hedged detail (fault names, variable names, cache hits) in Debug.
    • The backend still owns object-level authorisation.

    Practice exercises

    Starter (about 30 min): Write the requirements and assumptions table and the OpenAPI paths for your own three-operation API, with the Error schema. Map each decision to the lesson that implements it.

    Intermediate (about 2 h): Complete lab steps 1 to 7. Run the negative-test table and record the fault.name Debug shows for each OAuth failure, then adjust the unauthenticated rule.

    Advanced (half a day): Finish lab steps 8 to 10, then write a design note for one next-step extension (AI endpoint or second region) before building it.

    Official documentation

    Enroll to keep reading

    Lesson 11 of 11 · Apigee API Management: Beginner to Advanced

    Build a partner Orders API on Apigee from spec to production: OAuth, tiered products, quotas, caching, an error contract, CI/CD and a readiness review.

    In the rest of this lesson

    • Step 4: traffic and performance
    • Step 5: faults and observability
    • Step 6: products, developers and apps
    • Step 7: environments and deployment
    • Step 8: the CI/CD pipeline
    • Step 9: production readiness review

    Enrolling is free. It unlocks every lesson in Apigee API Management: Beginner to Advanced, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments