End-to-end: a secured API from gateway to portal¶
This tutorial connects three pieces: the Platform API control plane, a gateway, and the API Portal. You'll publish an API that requires both a subscription token and an API key on every call. Then you'll generate both credentials in the portal and watch the gateway accept them.
The point of the exercise is the seam in the middle. The portal never talks to the gateway. When a developer subscribes or generates a key, the portal fires a signed webhook to the Platform API, which persists the credential and pushes it to every gateway where the API is deployed. Getting that seam right is most of the work, and most of this tutorial.
What you'll build¶
API Portal ──signed webhook──▶ Platform API ──control plane──▶ Gateway
(issues the (verifies, decrypts, (enforces
credentials) persists, broadcasts) on each call)
▲
consumer's request ───────────┘
API-Key + Subscription-Key
Prerequisites¶
- Docker with the Compose plugin,
curl,unzip,jq, andopenssl - Free ports: 9543 (portal), 9243 (Platform API), 9090 and 9094 (gateway management and admin), 8080 (gateway API listener)
Step 1: Start the control plane and portal¶
The API Portal distribution ships the Platform API alongside it, so one compose file gives you both.
curl -sLO https://github.com/wso2/api-platform/releases/download/api-portal%2Fv1.0.0/wso2apip-api-portal-1.0.0.zip
unzip wso2apip-api-portal-1.0.0.zip
cd wso2apip-api-portal-1.0.0
./scripts/setup.sh
docker compose up -d
setup.sh provisions the TLS certificate, encryption keys, the RS256 JWT keypair the two services share, and your admin credentials. Copy the admin password it prints—it's shown once.
Confirm both are up:
curl -fsk https://localhost:9243/health && echo " platform-api ok"
curl -fsk -o /dev/null https://localhost:9543/api-portal/default/views/default && echo "api-portal ok"
Now get a Platform API token and create a project to hold the API. Every Platform API call below uses this token, and the portal accepts the same one—it verifies it against the shared public key.
export ADMIN_USERNAME=admin
export ADMIN_PASSWORD='<the password setup.sh printed>'
export TOKEN=$(curl -sk -X POST https://localhost:9243/api/portal/v0.9/auth/login \
-d "username=$ADMIN_USERNAME&password=$ADMIN_PASSWORD" | jq -r .token)
export PROJECT_ID=$(curl -sk -X POST https://localhost:9243/api/v0.9/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"id":"my-project","displayName":"My Project","description":"My tutorial project"}' | jq -r .id)
echo "project: $PROJECT_ID"
Step 2: Register the gateway, then start it¶
A gateway has to be registered with the Platform API before it can join. Registration returns an id; a second call mints the token the gateway authenticates with.
export GW_NAME=my-gateway
curl -sk -X POST https://localhost:9243/api/v0.9/gateways \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"id\":\"$GW_NAME\",\"displayName\":\"$GW_NAME\",\"endpoints\":[\"http://localhost:8080\"],\"functionalityType\":\"regular\"}" | jq -r .id
export GW_TOKEN=$(curl -sk -X POST https://localhost:9243/api/v0.9/gateways/$GW_NAME/tokens \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}' | jq -r .token)
Download the gateway distribution and run its setup:
cd ..
curl -sLO https://github.com/wso2/api-platform/releases/download/gateway%2Fv1.2.0-rc/wso2apip-api-gateway-1.2.0-rc.zip
unzip wso2apip-api-gateway-1.2.0-rc.zip
cd wso2apip-api-gateway-1.2.0-rc
./scripts/setup.sh
Point the controller at the control plane by adding these to api-platform.env, then start it:
cat >> api-platform.env <<EOF
APIP_GW_CONTROLLER_CONTROLPLANE_HOST=host.docker.internal:9243
APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=$GW_TOKEN
APIP_GW_CONTROLLER_CONTROLPLANE_GATEWAY_NAME=$GW_NAME
APIP_GW_CONTROLLER_CONTROLPLANE_INSECURE_SKIP_VERIFY=true
EOF
docker compose up -d
curl -fs http://localhost:9094/api/admin/v1/health && echo " gateway ok"
APIP_GW_CONTROLLER_CONTROLPLANE_GATEWAY_NAME must match the id you registered, and INSECURE_SKIP_VERIFY is needed here only because setup.sh generated a self-signed certificate. CONTROLPLANE_HOST is a host:port with no scheme—the controller prepends https:// and wss:// itself, so adding https:// here makes the connection fail with a lookup https: no such host error.
Note
host.docker.internal lets the gateway containers reach the Platform API published on your host. On Linux without that alias, put both stacks on one Docker network and use the service name instead.
Step 3: Connect the portal's webhooks to the Platform API¶
This is the seam. Two things have to be true before a portal-issued credential can reach the gateway.
First, link the portal's organization to the control plane. The Platform API resolves each incoming event's organization by handle, read from org.ref_id, which comes from the portal organization's cpRefId:
curl -sk -X PUT https://localhost:9543/api-portal/api/v0.9/organizations/default \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"id":"default","displayName":"Default","idpRefId":"default","cpRefId":"default"}'
The update requires id, displayName, and idpRefId. The id and idpRefId are fixed at seed time and can't change, so pass their existing values (both default here)—only cpRefId is new.
You can set the same field from the portal UI instead: Settings > Organization, under the ORGANIZATION group, in the Control plane reference ID field. See Organization settings.
Second, register the Platform API as a webhook subscriber. The secret here does double duty: it signs each delivery and derives the key that encrypts the credential fields. It must equal APIP_CP_WEBHOOK_SECRET on the Platform API, or signature verification and decryption both fail.
export WEBHOOK_SECRET=$(openssl rand -hex 32)
curl -sk -X POST https://localhost:9543/api-portal/api/v0.9/webhook-subscribers \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"id\":\"platform-api\",\"displayName\":\"Platform API\",
\"targetUrl\":\"https://platform-api:9243/api/internal/v0.9/webhook/events\",
\"secret\":\"$WEBHOOK_SECRET\",
\"events\":[\"apikey.*\",\"subscription.*\"],\"enabled\":true}"
The Platform API's webhook receiver ships disabled, and the shipped configs/config.toml has no [platform_api.webhook] section wiring in a secret—setting APIP_CP_WEBHOOK_SECRET alone does nothing. Enable the receiver and point it at the same secret, then set the value in api-platform.env and restart the Platform API so both changes take effect:
cd ../wso2apip-api-portal-1.0.0
cat >> configs/config.toml <<'EOF'
[platform_api.webhook]
enabled = true
secret = '{{ env "APIP_CP_WEBHOOK_SECRET" }}'
EOF
echo "APIP_CP_WEBHOOK_SECRET=$WEBHOOK_SECRET" >> api-platform.env
chmod 600 api-platform.env
docker compose up -d platform-api
Without this, every delivery fails with a plain 404
The route isn't conditionally rejecting the request—it's never registered on the server at all while enabled is false, so any delivery attempt gets a bare 404 with no error detail to explain why.
Warning
api-platform.env now holds a live shared secret, alongside the admin password hash. Keep it readable only by its owner, and never commit it to source control.
targetUrl uses the container name because the portal reaches the Platform API across the Docker network, not through your host's published port.
Webhook deliveries fail with a TLS error
The portal and the Platform API share a self-signed certificate, mounted into the api-portal container at /etc/api-portal/tls/cert.pem. Node doesn't trust it by default, so a delivery to https://platform-api:9243/... fails certificate verification until the portal process is told to trust that certificate. Point NODE_EXTRA_CA_CERTS at it and restart the portal:
You can register the same subscriber from the portal UI instead: Settings > Webhooks, under INTEGRATIONS, with + Add webhook. See Webhook Integration.
Step 4: Create the secured API¶
Create a subscription plan on the Platform API:
export PLAN=gold
curl -sk -X POST https://localhost:9243/api/v0.9/subscription-plans \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"id\":\"$PLAN\",\"displayName\":\"$PLAN\",\"status\":\"ACTIVE\",
\"limits\":[{\"limitType\":\"REQUEST_COUNT\",\"timeUnit\":\"HOUR\",\"limitCount\":10000}]}"
Give each plan a unique display name
The gateway stores plans keyed by gateway and display name. Two plans sharing one display name collide, and the second one—along with its subscriptions—silently fails to sync.
Now the API. Two policies do the enforcing: api-key-auth reads the key from a header you name, and subscription-validation reads the subscription token from another.
export API_ID=$(curl -sk -X POST https://localhost:9243/api/v0.9/rest-apis \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"displayName\":\"Reading List API\",\"context\":\"/reading-list/\$version\",\"version\":\"v1\",
\"projectId\":\"$PROJECT_ID\",\"lifeCycleStatus\":\"PUBLISHED\",
\"subscriptionPlans\":[\"$PLAN\"],
\"upstream\":{\"main\":{\"url\":\"https://apis.bijira.dev/samples/reading-list-api-service/v1.0\"}},
\"policies\":[
{\"name\":\"api-key-auth\",\"version\":\"v1\",
\"params\":{\"key\":\"API-Key\",\"in\":\"header\"}},
{\"name\":\"subscription-validation\",\"version\":\"v1\",
\"params\":{\"subscriptionKeyHeader\":\"Subscription-Key\"}}
]}" | jq -r .id)
The header names are the gateway's, set here—API-Key and Subscription-Key are the policy defaults, and changing these params changes what consumers must send.
Deploy it to the gateway, then confirm the route is live and enforcing. The deployment takes a name label, a base source (current deploys the latest working copy), and the target gatewayId:
curl -sk -X POST https://localhost:9243/api/v0.9/rest-apis/$API_ID/deployments \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"name\":\"v1.0\",\"base\":\"current\",\"gatewayId\":\"$GW_NAME\"}"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/reading-list/v1/books
Expect 401 or 403. A 404 means the route isn't programmed yet—wait a few seconds and retry.
Deploy before issuing credentials
Do not create the subscription or key first. The Platform API only broadcasts credential events to gateways where the API is already deployed, and issuing an API key while no gateway is connected returns 503. Once the API is deployed, credentials propagate live over the control-plane connection—no restart needed.
Step 5: Mirror the API and plan into the portal¶
The Platform API resolves each event's API and plan by handle, so the portal's copies have to carry matching references. Together with the organization link from step 3, that's three linkages that must line up:
| Portal field | Must equal |
|---|---|
Organization cpRefId |
The Platform API organization handle (default) |
API referenceId |
The Platform API API handle |
Plan refId |
The Platform API plan handle |
Sync the plan:
curl -sk -X PUT https://localhost:9543/api-portal/api/v0.9/subscription-plans \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"id\":\"$PLAN\",\"displayName\":\"$PLAN\",\"refId\":\"$PLAN\",
\"limits\":[{\"limitType\":\"REQUEST_COUNT\",\"limitCount\":10000,
\"timeUnit\":\"HOUR\",\"timeAmount\":1}]}"
You can set the same refId from the portal UI instead: Settings > Subscription Plans, under ORGANIZATION, in the External reference ID field when adding or editing the plan. See Subscription plans.
Then publish the API to the portal with referenceId set to the Platform API handle, and its specification attached. Adding an API is also possible from Settings > APIs, under CONTENT, with + Add API—see Manage APIs—but that wizard has no field for referenceId, so this particular linkage still needs the manifest and the Management API. Create the manifest, setting referenceId to the value of $API_ID and listing $PLAN under subscriptionPlans:
cat > api.yaml <<EOF
apiVersion: api-portal.api-platform.wso2.com/v1
kind: RestApi
metadata:
name: reading-list-api-v1
spec:
type: REST
displayName: Reading List API
version: v1
description: Track a personal reading list. Every call requires an API key and a subscription token.
status: PUBLISHED
referenceId: $API_ID
tags:
- reading-list
labels:
- default
subscriptionPlans:
- $PLAN
agentVisibility: VISIBLE
businessInformation:
businessOwner: Platform Owner
businessOwnerEmail: [email protected]
technicalOwner: API Team
technicalOwnerEmail: [email protected]
endpoints:
sandboxUrl: http://localhost:8080/reading-list/v1
productionUrl: http://localhost:8080/reading-list/v1
EOF
Create the matching OpenAPI definition:
cat > definition.yaml <<'EOF'
openapi: 3.0.1
info:
title: Reading List API
version: v1
description: |
Track a personal reading list — add books, update their reading status, and
remove them when you're done. Requires an API key and a subscription token.
servers:
- url: http://localhost:8080/reading-list/v1
security:
- ApiKeyHeader: []
components:
securitySchemes:
ApiKeyHeader:
type: apiKey
in: header
name: API-Key
parameters:
SubscriptionKeyHeader:
name: Subscription-Key
x-header-type: subscription-key
in: header
required: true
schema:
type: string
schemas:
Book:
type: object
required: [title, author, status]
properties:
id:
type: string
format: uuid
readOnly: true
title:
type: string
example: The Great Gatsby
author:
type: string
example: F. Scott Fitzgerald
status:
type: string
enum: [to_read, reading, read]
paths:
/books:
parameters:
- $ref: '#/components/parameters/SubscriptionKeyHeader'
get:
summary: List books
responses:
'200':
description: OK. The reading list.
post:
summary: Add a book
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Book'
responses:
'201':
description: Created. The newly added book.
/books/{id}:
parameters:
- $ref: '#/components/parameters/SubscriptionKeyHeader'
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Get a book
responses:
'200':
description: OK. The requested book.
put:
summary: Update a book
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Book'
responses:
'200':
description: OK. The updated book.
delete:
summary: Remove a book
responses:
'204':
description: No Content. The book was removed.
EOF
Publish both files:
curl -sk -X POST https://localhost:9543/api-portal/api/v0.9/apis \
-H "Authorization: Bearer $TOKEN" \
-F "[email protected];type=application/yaml" \
-F "[email protected];type=application/yaml"
Open https://localhost:9543/api-portal/default/views/default/apis. The API appears in the catalog with its plan.
Step 6: Get both credentials in the portal¶
Do this part in the portal UI—it's what a developer would actually do. Sign in with the admin credentials.
- Open the API and click Subscribe on the plan. Copy the subscription token from the dialog.
- Click API Keys, then Generate API key. Give it a name and copy the key.
Each action fires a webhook the Platform API turns into gateway state. For the equivalent REST calls, see Subscriptions and API Keys.
Step 7: Invoke through the gateway¶
Send both credentials, in the headers the policies named:
export API_KEY='<the key from the portal>'
export SUB_TOKEN='<the subscription token from the portal>'
curl -i http://localhost:8080/reading-list/v1/books \
-H "API-Key: $API_KEY" \
-H "Subscription-Key: $SUB_TOKEN"
A 200 means the whole chain worked: the portal issued the credentials, signed and encrypted them into a webhook, the Platform API verified and decrypted them, and the gateway is now enforcing them on live traffic.
Propagation takes a moment. If you get a 401 or 403 immediately after generating the credentials, retry after a few seconds.
Confirm each credential is really being checked¶
Drop one header at a time—each should be rejected:
curl -s -o /dev/null -w 'no credentials: %{http_code}\n' http://localhost:8080/reading-list/v1/books
curl -s -o /dev/null -w 'key only: %{http_code}\n' http://localhost:8080/reading-list/v1/books -H "API-Key: $API_KEY"
curl -s -o /dev/null -w 'subscription only: %{http_code}\n' http://localhost:8080/reading-list/v1/books -H "Subscription-Key: $SUB_TOKEN"
Step 8: Watch a lifecycle change propagate¶
Credential changes travel the same path. In the portal, revoke the API key, then call again with it:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/reading-list/v1/books \
-H "API-Key: $API_KEY" -H "Subscription-Key: $SUB_TOKEN"
Once the webhook lands you get 401—the key is gone from the gateway without anyone touching the gateway. Generate a new key and the call succeeds again.
The same holds for the subscription side, where a rejection reads as 403 rather than 401: suspending the subscription blocks calls, resuming restores them, and regenerating the token invalidates the old one while the new one works. See Manage Subscriptions.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
404 at the gateway |
The route isn't programmed yet, or the API wasn't deployed to this gateway |
503 when generating an API key |
No gateway is connected for that API—deploy it first |
| Credentials never start working | The webhook secret differs between the portal subscriber and APIP_CP_WEBHOOK_SECRET, so signatures fail and credential fields can't be decrypted |
Only the subscription fails (403) |
The portal plan's refId doesn't match the Platform API plan handle, or two plans share a display name |
Every delivery gets a 404 |
The Platform API's webhook receiver isn't enabled—add [platform_api.webhook] with enabled = true to configs/config.toml and restart it |
| Nothing arrives at all | The organization's cpRefId doesn't match the Platform API organization handle |
| Deliveries fail once and stop | Webhook delivery is attempted exactly once with no retry—check delivery history, see Webhook Events |
Related¶
- Webhook Event Catalog: the events this flow depends on, their payloads, and the signing and encryption scheme
- Webhook Integration: registering a subscriber from the Settings UI
- Consume an API: which credentials an API expects, and how to tell
- Manage Subscriptions and Manage API Keys: the consumer-side lifecycles
- Getting Started: the portal on its own, without a gateway