SampleSampletypeA worked example you read or clone. Evidence: it demonstrates, it never provides.See every one →What this type is →by SAPby SAPoriginPublished by SAP itself. A visible facet, never an input to a reading — a dormant official plugin reads exactly like a dormant independent one.See every one →Reference

SPFI-Reference-Application

A Spring Boot reference application that demonstrates how to implement the SPFI V2 REST API contract for tenant lifecycle management between a provisioning operator and a service provider.

A SaaS provider whose tenants are to be managed by SAP's Unified Provisioning platform has to expose a specific contract, and this application demonstrates how to implement it: the standard interface between a Unified Provisioning operator and a service provider for tenant lifecycle management and SSO configuration. It covers the tenant endpoints for provisioning, updating, deleting and blocking or unblocking a tenant, secured with mutual TLS and made asynchronous through 202 Accepted responses with Retry-After polling, plus optimistic concurrency on updates through ETag and If-Match, and a second activation call for the same sapId returns 409 Conflict with the existing tenant's status URL. It covers the provider side only: the operator is SAP's Unified Provisioning platform and is not part of the repository.

Facts

Last pushLicenceApache-2.0

Where it sits

Lives in
Demonstrates
MultitenancyBTPMultitenancycapabilityServing many tenants from one deployment — onboarding, tenant-isolated data, per-tenant schema deployment and the operational tooling around it. It is decided once, before anything is built, and narrows the artifacts in almost every capability downstream: most sharply in [caching](capability:caching) and anything else holding state in process.

Who is behind it

SAP SamplesSAP SamplesOrganisationMaintains this
In the atlas: maintains 222
@SAP-samples
maintainerAjinkya PatilPersonContributes to this
In the atlas: contributes to 112
@ajinkyapatil8190
Gauravi ShuklaPersonContributes to this
In the atlas: contributes to 1
@GauraviShuklaSAP
ManoharGuptaKancharlaPersonContributes to this
In the atlas: contributes to 1
@ManoharGuptaKancharla
3 contributors

Before choosing this

Conditions

PracticalFunctional boundary

Not as the provider behind a live tenant estate: tenant state is kept in a file-based store for demonstration, and the README's own operations notes say a production implementation should use a reliable database so that state survives restarts.

What it declares

Every artifact

Type
Sample
Origin
By SAP
Use as
A reference

As a sample

Form
A reference application

Connections1

SPFI-Reference-Application connectionsSPFI-Reference-ApplicationPROVIDES→ demonstrates1demonstrates authoredMultitenancy

README

quoted from SAP-samples/SPFI-Reference-Application under Apache-2.0, read
SAP-samples/SPFI-Reference-Application/README.mdOn GitHub

SPFI V2 Reference Application

A Spring Boot reference application that demonstrates how to implement the SPFI V2 REST API contract — the standard interface between a Unified Provisioning operator and a service provider for tenant lifecycle management and SSO configuration.


Table of Contents


SPFI V2 Contract

What is the SPFI V2 Contract?

SPFI stands for Service Provider Fulfillment Interface. Version 2 defines the REST API that any service provider must expose so that the Unified Provisioning platform (the operator) can manage the full lifecycle of a tenant on that provider's system.

Key characteristics of the contract:

  • Transport security: All calls are protected by mutual TLS (mTLS). The operator presents a client certificate; the provider's server validates it.
  • Asynchronous by design: Long-running operations (provisioning, update, deletion, SSO activation) return 202 Accepted with a Location header pointing to a polling endpoint. Short operations may return 201 Created or 200 OK immediately.
  • Polling via Retry-After: Every 202 response carries a Retry-After header telling the operator how many seconds to wait before polling the status endpoint again.
  • Optimistic concurrency for updates: The GET /tenants/{id} response includes an ETag header. PUT /tenants/{id} requires the caller to pass that ETag in an If-Match header. A mismatch returns 409 Conflict.
  • Idempotent activation: A second POST /v2/tenants for the same sapId returns 409 Conflict with the existing tenant's status URL, so the operator can resume polling instead of creating a duplicate.

Architecture Overview
Unified Provisioning Service Provider — SPFI V2 Reference App HTTPS + mTLS (cert from SAP PKI) Tenant Operator Tenant APIsPOST /v2/tenants GET /v2/tenants GET /v2/tenants/{id} PUT /v2/tenants/{id} DELETE /v2/tenants/{id} GET /v2/tenants/{id}/status POST /v2/tenants/{id}/status

API Endpoints
Tenant Lifecycle Endpoints
MethodPathDescriptionResponse Codes
POST/v2/tenantsActivate (provision) a new tenant202 Accepted, 409 Conflict
GET/v2/tenantsList all tenants200 OK
GET/v2/tenants/{tenantId}Get a specific tenant (returns ETag header)200 OK, 404 Not Found
PUT/v2/tenants/{tenantId}Update a tenant (requires If-Match header)202 Accepted, 409 Conflict
DELETE/v2/tenants/{tenantId}Delete (deprovision) a tenant202 Accepted, 409 Conflict
GET/v2/tenants/{tenantId}/statusPoll the current status of an operation200 OK, 404 Not Found
POST/v2/tenants/{tenantId}/statusTrigger a lifecycle state change (block/unblock)202 Accepted, 409 Conflict

Note: For the full list of response codes and error schemas, refer to the Swagger Documentation.



Tenant Lifecycle Flows
Provisioning (Slow Path)

The typical path for provisioning a new tenant. The provider starts the work asynchronously and the operator polls for completion.

POST /v2/tenantsBody: { sapId, customer, location, ... } 202 AcceptedLocation: /v2/tenants/{id}/statusRetry-After: 20 GET /v2/tenants/{id}/status 200 OK { state: "In Activation" }Retry-After: 20 GET /v2/tenants/{id}/status 200 OK { state: "Active" } GET /v2/tenants/{id} 200 OK { tenant object }ETag: "abc123" Persists tenantState → "In Activation" waits Retry-After seconds work completes waits Retry-After seconds Operator Service Provider

Duplicate activation (idempotency):

POST /v2/tenants (same sapId again) 409 ConflictLocation: /v2/tenants/{existingId}/statusBody: { errorCode: "409-01", ... } resumes polling existing location Operator Service Provider
Update Flow

Tenant updates use optimistic concurrency. The operator must supply the ETag from the most recent GET.

GET /v2/tenants/{id} 200 OK + ETag: "abc123" PUT /v2/tenants/{id}If-Match: "abc123"Body: { updated tenant fields } 202 AcceptedLocation: /v2/tenants/{id}/status Validates ETagState → "In Update" polls /status until state = "Active" Operator Service Provider

ETag mismatch (concurrent modification):

PUT /v2/tenants/{id}If-Match: "stale-etag" 409 ConflictBody: { errorCode: "409-03", "Entity Tag Mismatch" } Operator Service Provider

Note: Update is only allowed when the tenant is in Active state.

Deletion Flow
DELETE /v2/tenants/{id} 202 AcceptedLocation: /v2/tenants/{id}/status GET /v2/tenants/{id}/status 404 Not Found (tenant no longer exists) Validates not in-progressState → "In Deletion" polls /status deletion completes Operator Service Provider

Note: Deletion is rejected if the tenant is currently in any in-progress state (In Activation, In Update, In Deletion, In Blocking).

Lifecycle State Change (Block / Unblock)
POST /v2/tenants/{id}/statusBody: { "state": "blocked" } 202 AcceptedRetry-After: 20 GET /v2/tenants/{id}/status 200 OK { state: "Blocked" } Validates transitionState → "In Blocking" blocking completes Operator Service Provider

Allowed state transitions via POST /status:

Current StateAllowed Target States
Activeblocked
Blockedactive
In Recoverable Erroractive
Any in-progress state(not allowed)

Data Model
Tenant Object
{
  "id": "<uuid>",
  "sapId": "<CRM tenant ID>",
  "businessType": "production",
  "operationType": "standard",
  "application": {
    "globalTenantId": "<global tenant ID>",
    "endpoints": [
      { "type": "application",    "url": "https://app.example.com",             "displayName": "Application URL",   "properties": {} },
      { "type": "configuration",  "url": "https://app.example.com/v2/tenants/…", "displayName": "Configuration URL", "properties": {} },
      { "type": "auditlog",       "url": "https://app.example.com/auditlog/…",   "displayName": "Auditlog URL",      "properties": {} }
    ],
    "additionalProperties": {
      "key1": "value1",
      "key2": "value2"
    }
  },
  "customer": {
    "id": "<customer ID>",
    "name": "<customer name>",
    "erpId": "<ERP ID>",
    "contact": {
      "id": "<contact ID>",
      "name": "<contact name>",
      "email": "<email>",
      "phone": "<phone>"
    }
  },
  "location": {
    "geography": {
      "region": "<region>",
      "continent": "<continent>",
      "country": "<country>",
      "city": "<city>"
    },
    "infrastructure": {
      "platform": "<platform>",
      "region": "<region>",
      "zone": "<zone>",
      "dataCenter": "<data center>",
      "landscape": "<landscape>",
      "environment": "<environment>",
      "host": "<host>",
      "internalIdentifier": "<internal ID>"
    }
  },
  "contract": {
    "validFrom": "<ISO date>",
    "validTo": "<ISO date>"
  },
  "products": [
    { "productId": "<SKU ID>", "quota": 100, "unit": "users" }
  ],
  "initialUsers": [
    { "id": "<user ID>", "email": "<email>", "firstName": "<first>", "lastName": "<last>" }
  ],
  "additionalProperties": { "key": "value" },
  "hostTenantSpecification": {
    "group": "<api group>",
    "version": "<version>",
    "type": "<type>",
    "spec": "<JSON string>"
  },
  "mixinSpecifications": [],
  "status": {
    "state": "Active",
    "lastModified": "<ISO timestamp>",
    "details": {}
  }
}

Required fields on POST: sapId, customer (with name, email), location, at least one initialUser.

Status Object
{
  "state": "In Activation",
  "lastModified": "2024-01-15T10:30:00Z",
  "details": {
    "message": "Tenant provisioning is in progress"
  }
}
State Change Request (POST /status)
{ "state": "active" }

or

{ "state": "blocked" }

Recommendations
Security (High)
  • Verify the certificate chain is complete (intermediate CAs included).
  • Check certificate expiration: openssl x509 -in cert.pem -noout -dates
  • Ensure the server's hostname matches the CN or SAN in the server certificate.
  • Validate the CN against the response of the /certInfo endpoint from the Operator.
Good to Have
  • Generate tenant IDs as GUIDs (UUID v4). The id assigned to a tenant on creation should be a randomly generated UUID (e.g. 550e8400-e29b-41d4-a716-446655440000). This ensures global uniqueness across tenants and systems, avoids predictable or sequential IDs that could be guessed, and aligns with the format expected by the operator when storing and referencing the tenant.
API Implementation
  • Always return Retry-After on 202 responses. The operator uses this value to schedule its next poll. A missing or very short value can cause tight polling loops that overload the provider.
  • Implement ETag correctly. Generate the ETag from a hash of the tenant's mutable fields or a version counter. Never return a static or empty ETag — it breaks concurrent update detection.
  • Implement idempotency for POST /v2/tenants. Check for an existing tenant with the same sapId before creating a new one. Return 409 with the existing tenant's status URL rather than creating a duplicate.
  • Validate state transitions strictly. Reject DELETE and PUT requests when the tenant is in an in-progress state (In Activation, In Update, In Deletion, In Blocking). Returning a clear error message helps the operator handle retries correctly.
  • Propagate meaningful status details. The status.details field is surfaced to platform users when provisioning fails. Include actionable information rather than internal stack traces.
  • Use Final Error state only for non-recoverable failures. The operator stops retrying on Final Error. Use In Recoverable Error or In Self-Recoverable Error for transient issues.
Operations
  • Configure HEADER_RETRY_AFTER_SECONDS based on your actual provisioning time. Setting it too low increases polling load; too high delays the operator from detecting completion. A value of 20–60 seconds is typical for most operations.
  • Implement health endpoints (/health/liveness, /health/readiness) and permit them without mTLS so that the platform's load balancer can route traffic correctly.
  • Design the status endpoint to be stateless and fast. It will be called frequently during provisioning. Avoid database writes or heavy computation in GET /status handlers.
  • Persist tenant state durably. The reference application uses a file-based store for demonstration. Production implementations should use a reliable database so that state survives restarts.
  • Test the full async flow end-to-end. Unit tests verify logic, but integration tests that exercise the complete slow-path provisioning loop (including polling) catch timing and state-machine bugs that unit tests miss.

Application Setup

Security: mTLS

All API calls are protected with mutual TLS. The operator presents a client certificate issued by the SAP PKI service; the application server validates this certificate against a configured truststore.

Spring Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/health/**").permitAll()
                .anyRequest().authenticated()
            )
            .x509(x509 -> x509
                .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
                .userDetailsService(userDetailsService())
            );
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> new User(username, "",
            AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
    }
}

Running the Application

Local (development profile):

mvn spring-boot:run -Dspring-boot.run.profiles=local

Cloud Foundry:

The manifest.yaml configures the CF deployment:

env:
  SPRING_PROFILES_ACTIVE: "cf"
  HEADER_RETRY_AFTER_SECONDS: "20"

The HEADER_RETRY_AFTER_SECONDS variable controls the value returned in Retry-After headers (default: 120 seconds if not set).


Testing with curl
# List tenants
curl https://<host>/v2/tenants \
  --cert certs/client-certificate_chain.pem \
  --key certs/private-key.pem \
  --cacert certs/ca.crt

# Activate a tenant
curl -X POST https://<host>/v2/tenants \
  -H "Content-Type: application/json" \
  --cert certs/client-certificate_chain.pem \
  --key certs/private-key.pem \
  --cacert certs/ca.crt \
  -d '{
    "sapId": "T-12345",
    "customer": {
      "id": "C-001",
      "name": "Acme Corp",
      "contact": { "name": "Jane", "email": "jane@acme.com", "phone": "+1234" }
    },
    "businessType": "production",
    "location": {
      "geography": { "region": "eu", "country": "DE" },
      "infrastructure": { "dataCenter": "eu10" }
    },
    "initialUsers": [{ "id": "U-001", "email": "admin@acme.com" }],
    "sso": [{
      "identityProvider": {
        "authProtocol": "SAML2",
        "metadataURL": "https://idp.acme.com/metadata"
      }
    }]
  }'

# Poll status
curl https://<host>/v2/tenants/{tenantId}/status \
  --cert certs/client-certificate_chain.pem \
  --key certs/private-key.pem \
  --cacert certs/ca.crt

Troubleshooting
Connection Refused
  • Verify the server is running on the expected port (default: 8443 for HTTPS).
  • Check that server.ssl.enabled=true is set in the active profile.
  • Confirm firewall or security group rules allow inbound HTTPS traffic.
409 Conflict on Activation
  • A tenant with the same sapId already exists. Follow the Location header in the response to resume polling the existing tenant's status URL.
409 Conflict on Update (ETag Mismatch)
  • Re-fetch the tenant with GET /v2/tenants/{id} to obtain a fresh ETag, then retry the PUT.
403 / 401 on Every Request
  • Verify the client certificate is signed by a CA that is in the server's truststore.
  • Check that server.ssl.client-auth=need is configured.
  • Confirm the certificate validation filter is not rejecting the CN of the operator's certificate.

References
Apache-2.0, read

Details

Sources3
Added by handdiscovered herechecked
github.com/SAP-samples/SPFI-Reference-Applicationtree, pushes, contributors, CIchecked
README.mdquoted, and what the entry declareschecked

Sources are graph entities: the same page may back several entries, and an unreachable source flags every row that rests on it.

Compatibility and provenance
discovered viaAdded by hand
first seen in the atlas
ecosystem and domainbtp, operations-and-runtime
Contributors3

From the repository’s contributor list, .

History in the atlas1
  1. First seen.
For your agent
Ref
artifact:sap-samples-spfi-reference-application
JSON
/artifacts/sap-samples-spfi-reference-application.json
MCP
Connecting an agent