> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twine.se/llms.txt
> Use this file to discover all available pages before exploring further.

# SCIM

export const IntegrationMeta = ({homepageUrl, apiReferenceUrls, contactEmail, transport, status, domains, capabilities, notes}) => {
  const hasLinks = homepageUrl || apiReferenceUrls && apiReferenceUrls.length > 0 || contactEmail;
  const hasDomains = domains && domains.length > 0;
  const hasTransport = !!transport;
  const hasStatus = status === 'planned' || status === 'deprecated';
  const hasCapabilities = capabilities && capabilities.length > 0;
  const hasNotes = !!notes;
  if (!hasLinks && !hasDomains && !hasTransport && !hasStatus && !hasCapabilities && !hasNotes) return null;
  const pill = (bg, color, text) => <span style={{
    display: 'inline-flex',
    alignItems: 'center',
    fontSize: '11px',
    fontWeight: 600,
    lineHeight: 1,
    padding: '3px 8px',
    borderRadius: '999px',
    background: bg,
    color: color,
    whiteSpace: 'nowrap'
  }}>
  {text}
</span>;
  const capabilityColors = {
    'read/write': ['var(--colors-accent-light, #ede9fe)', 'var(--colors-accent, #7c3aed)'],
    'read-write': ['var(--colors-accent-light, #ede9fe)', 'var(--colors-accent, #7c3aed)'],
    'read': ['rgb(var(--primary-light) / .15)', 'var(--colors-content-secondary, #6b7280)'],
    'write': ['rgb(var(--primary-light) / .15)', 'var(--colors-content-secondary, #6b7280)']
  };
  const transportColors = {
    'file': ['var(--colors-warning-light, #fef3c7)', 'var(--colors-warning, #b45309)'],
    'api': ['var(--colors-accent-light, #ede9fe)', 'var(--colors-accent, #7c3aed)'],
    'webhook': ['var(--colors-success-light, #dcfce7)', 'var(--colors-success, #16a34a)']
  };
  const statusConfig = {
    'planned': {
      colors: ['var(--colors-warning-light, #fef3c7)', 'var(--colors-warning, #b45309)'],
      text: 'Planned'
    },
    'deprecated': {
      colors: ['rgb(var(--primary-light) / .15)', 'var(--colors-content-secondary, #6b7280)'],
      text: 'Deprecated'
    }
  };
  const label = text => <span style={{
    fontSize: '11px',
    fontWeight: 600,
    textTransform: 'uppercase',
    letterSpacing: '0.05em',
    color: 'var(--colors-content-secondary, #6b7280)',
    whiteSpace: 'nowrap',
    alignSelf: 'start',
    paddingTop: '2px'
  }}>
  {text}
</span>;
  const cell = children => <div style={{
    display: 'flex',
    alignItems: 'center',
    flexWrap: 'wrap',
    gap: '8px'
  }}>
  {children}
</div>;
  return <>
<style>{`
  .integration-meta-link {
    font-size: 13px;
    color: var(--colors-primary);
    text-decoration: none !important;
    border-bottom: none !important;
    box-shadow: none !important;
    position: relative;
  }
  .integration-meta-link:hover {
    text-decoration: none !important;
    border-bottom: none !important;
    box-shadow: none !important;
  }
  .integration-meta-link::after {
    content: '';
    position: absolute;
    left: 0;
    bottom: -1px;
    width: 100%;
    height: 1px;
    background: rgb(var(--primary-light));
    opacity: 0.5;
    transition: opacity 0.15s;
  }
  .integration-meta-link:hover::after {
    opacity: 1;
  }
`}</style>
<div style={{
    display: 'grid',
    gridTemplateColumns: 'max-content 1fr',
    alignItems: 'center',
    gap: '8px 16px',
    padding: '14px 16px',
    borderRadius: '8px',
    background: 'rgb(var(--primary-light) / .05)',
    border: '1px solid rgb(var(--primary-light) / .1)',
    marginBottom: '32px'
  }}>
{hasLinks && <>
{label('Links')}
{cell(<>
{homepageUrl && <a href={homepageUrl} target="\_blank" rel="noopener noreferrer" className="integration-meta-link">
Website
</a>}
{apiReferenceUrls && apiReferenceUrls.map(({url, label: l}) => <a key={url} href={url} target="\_blank" rel="noopener noreferrer" className="integration-meta-link">
{l || 'API Reference'}
</a>)}
{contactEmail && <a href={`mailto:${contactEmail}`} className="integration-meta-link">
{contactEmail}
</a>}
</>)}
</>}

      {hasStatus && <>
        {label('Status')}
        {cell(pill(...statusConfig[status].colors, statusConfig[status].text))}
      </>}

      {hasTransport && <>
        {label('Transport')}
        {cell(pill(...transportColors[transport] || transportColors['api'], transport === 'file' ? 'File' : transport === 'webhook' ? 'Webhook' : 'API'))}
      </>}

      {hasDomains && <>
        {label('Domains')}
        {cell(domains.map(({domain, capability}) => {
    const [capBg, capColor] = capabilityColors[capability] || capabilityColors['read'];
    return <span key={domain} style={{
      display: 'inline-flex',
      alignItems: 'stretch',
      borderRadius: '999px',
      overflow: 'hidden',
      whiteSpace: 'nowrap',
      fontSize: '12px',
      fontWeight: 600,
      lineHeight: 1
    }}>
              <span style={{
      padding: '4px 8px',
      background: 'rgb(var(--primary-light) / .3)',
      color: 'inherit',
      fontFamily: 'monospace'
    }}>{domain}</span>
              <span style={{
      padding: '4px 8px',
      background: capBg,
      color: capColor
    }}>{capability}</span>
            </span>;
  }))}
      </>}

      {hasCapabilities && <>
        {label('Capabilities')}
        <div style={{
    display: 'flex',
    flexDirection: 'column',
    gap: '4px'
  }}>
          {capabilities.map(({label: capLabel, supported}) => <div key={capLabel} style={{
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    fontSize: '13px'
  }}>
              <span style={{
    fontWeight: 700,
    fontSize: '12px',
    color: supported ? 'var(--colors-success, #16a34a)' : 'var(--colors-error, #dc2626)',
    width: '12px',
    flexShrink: 0
  }}>{supported ? '✓' : '✗'}</span>
              <span style={{
    color: 'inherit'
  }}>{capLabel}</span>
            </div>)}
        </div>
      </>}

      {hasNotes && <>
        {label('Notes')}
        <span style={{
    fontSize: '13px',
    color: 'var(--colors-content-secondary, #6b7280)',
    fontStyle: 'italic'
  }}>{notes}</span>
      </>}
    </div>

</>;
};

export const IntegrationHeader = ({name, category, logoUrl}) => <div style={{
  display: 'flex',
  alignItems: 'center',
  gap: '16px',
  marginBottom: '24px'
}}>
    {logoUrl && <div style={{
  width: '64px',
  height: '64px',
  borderRadius: '12px',
  background: '#ffffff',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  flexShrink: 0,
  boxShadow: '0 0 0 1px rgba(0,0,0,0.08)'
}}>
        <img src={logoUrl} alt={`${name} logo`} style={{
  width: '40px',
  height: '40px',
  objectFit: 'contain'
}} />
      </div>}
    <div>
      <h1 style={{
  margin: 0
}}>{name}</h1>
      <span style={{
  color: 'var(--colors-content-secondary)',
  fontSize: '14px'
}}>
        {category}
      </span>
    </div>
  </div>;

<IntegrationHeader name="SCIM" category="Identity Providers" logoUrl="https://kbmni.upcloudobjects.com/public/systemlogos/scim-logo.png" />

<IntegrationMeta
  homepageUrl="https://scim.cloud"
  apiReferenceUrls={[
{
  label: 'Specification',
  url: 'https://scim.cloud/#Specification',
},
{
  label: 'Entra API-driven inbound provisioning',
  url: 'https://learn.microsoft.com/en-us/entra/identity/app-provisioning/inbound-provisioning-api-configure-app',
},
]}
  transport="api"
  domains={[
{ domain: "employee", capability: "write" },
]}
  notes="Only the User resource is supported. Group provisioning is not yet implemented."
/>

Twine's SCIM integration supports two distinct flows that share the same configuration on the Twine side but use different parts of the SCIM specification.

## Receiving users (SCIM server)

Twine acts as a **SCIM server**. Customer identity providers act as SCIM clients and push user lifecycle operations - create, update, deactivate, and delete - to Twine's SCIM endpoints. Each operation is mapped into Twine's [Employee](/platform/data-model/employee) data model and stored on the Organization.

This is the most common SCIM setup. It works with any standards-compliant SCIM client.

### Authentication

For inbound SCIM, Twine generates a dedicated **role** in the customer's Organization with a **long-lived request token** attached to it. The token is presented as a bearer credential by the SCIM client on every request. This is a different mechanism from the refresh-token flow used by Twine's public API.

<Info>
  Token rotation details are still being finalised and will be documented here.
</Info>

## Pushing users (bulk upload to Entra)

Twine can also act as a **SCIM client** and push users in bulk to a customer's identity provider via the inbound provisioning API. This is currently supported for **Microsoft Entra**. Support for additional identity providers (Okta and others) may be added in the future.

The Entra inbound provisioning flow is documented at [Configure Microsoft Entra ID inbound provisioning](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/inbound-provisioning-api-configure-app).

### Authentication

Entra inbound provisioning uses **certificate-based authentication**. Twine generates the certificate and exposes the **public key** to the customer, who configures it on the Entra application. No client IDs or client secrets are involved, and the customer never handles the private key. The flow described in the linked Microsoft article is provided for context only - Twine does not use the client-secret-based path it describes.

## Supported domains

| Domain   | Inbound | Outbound         |
| -------- | ------- | ---------------- |
| Employee | Yes     | Yes (Entra only) |

Other SCIM resources, including Groups, are not currently supported.
