Skip to content

Commit 4ad2fbe

Browse files
billyvgclaude
andcommitted
feat(developer-settings): Add webhook headers field to custom integrations
Add a Webhook Headers textarea to the Integration Details form for internal and public custom integrations, directly below Webhook URL. Each line is a 'Header-Name: value' pair sent with every outgoing webhook request. Mirrors the allowedOrigins field exactly (convert/extract multiline helpers). Masked values returned by the API round-trip safely because the backend preserves the stored value for any entry resubmitted with the mask sentinel, so the form needs no special masking logic. Supersedes #116732, which split out this UI ahead of the backend. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7e0f76c commit 4ad2fbe

3 files changed

Lines changed: 71 additions & 0 deletions

File tree

static/app/types/integrations.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ export type SentryApp = {
246246
id: number;
247247
slug: string;
248248
};
249+
// Each entry is a "Header-Name: value" line. Values are masked by the API for
250+
// viewers without elevated access.
251+
webhookHeaders?: string[];
249252
};
250253

251254
// Minimal Sentry App representation for use with avatars

static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ describe('Sentry Application Details', () => {
124124
verifyInstall: true,
125125
isAlertable: true,
126126
allowedOrigins: [],
127+
webhookHeaders: [],
127128
schema: {},
128129
overview: '',
129130
};
@@ -136,6 +137,33 @@ describe('Sentry Application Details', () => {
136137
})
137138
);
138139
});
140+
141+
it('saves webhook headers', async () => {
142+
renderComponent();
143+
144+
await userEvent.type(screen.getByRole('textbox', {name: 'Name'}), 'Test App');
145+
await userEvent.type(screen.getByRole('textbox', {name: 'Author'}), 'Sentry');
146+
await userEvent.type(
147+
screen.getByRole('textbox', {name: 'Webhook URL'}),
148+
'https://webhook.com'
149+
);
150+
await userEvent.type(
151+
screen.getByRole('textbox', {name: 'Webhook Headers'}),
152+
'X-Example: value'
153+
);
154+
155+
await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
156+
157+
expect(createAppRequest).toHaveBeenCalledWith(
158+
'/sentry-apps/',
159+
expect.objectContaining({
160+
data: expect.objectContaining({
161+
webhookHeaders: ['X-Example: value'],
162+
}),
163+
method: 'POST',
164+
})
165+
);
166+
});
139167
});
140168

141169
describe('Creating a new internal Sentry App', () => {
@@ -229,6 +257,20 @@ describe('Sentry Application Details', () => {
229257
expect(screen.getByRole('textbox', {name: 'Client ID'})).toBeInTheDocument();
230258
expect(screen.getByRole('textbox', {name: 'Client Secret'})).toBeInTheDocument();
231259
});
260+
261+
it('prefills webhook headers from the app', async () => {
262+
sentryApp.webhookHeaders = ['X-Example: value', 'Another-Header: thing'];
263+
MockApiClient.addMockResponse({
264+
url: `/sentry-apps/${sentryApp.slug}/`,
265+
body: sentryApp,
266+
});
267+
268+
renderComponent();
269+
270+
expect(await screen.findByRole('textbox', {name: 'Webhook Headers'})).toHaveValue(
271+
'X-Example: value\nAnother-Header: thing'
272+
);
273+
});
232274
});
233275

234276
describe('Renders for internal apps', () => {

static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const sentryAppFormSchema = z
8686
name: z.string(),
8787
author: z.string(),
8888
webhookUrl: z.string(),
89+
webhookHeaders: z.string(),
8990
redirectUrl: z.string(),
9091
verifyInstall: z.boolean(),
9192
isAlertable: z.boolean(),
@@ -197,6 +198,7 @@ type SaveSentryAppPayload = {
197198
author?: string | null;
198199
overview?: string;
199200
redirectUrl?: string;
201+
webhookHeaders?: string[];
200202
webhookUrl?: string;
201203
};
202204

@@ -514,6 +516,9 @@ function SentryApplicationForm({
514516
schema: getSchemaFieldValue(app?.schema),
515517
overview: app?.overview ?? '',
516518
allowedOrigins: convertMultilineFieldValue(app?.allowedOrigins ?? []),
519+
// Masked values (Header-Name: ***) round-trip safely: the backend preserves
520+
// the stored value for any entry resubmitted with the mask sentinel.
521+
webhookHeaders: convertMultilineFieldValue(app?.webhookHeaders ?? []),
517522
organization: organization.slug,
518523
isInternal,
519524
scopes: app ? [...app.scopes] : [],
@@ -553,6 +558,7 @@ function SentryApplicationForm({
553558
scopes: value.scopes,
554559
events: value.events,
555560
allowedOrigins: extractMultilineFields(value.allowedOrigins),
561+
webhookHeaders: extractMultilineFields(value.webhookHeaders),
556562
schema: value.schema.trim() === '' ? {} : JSON.parse(value.schema),
557563
// The author parser doesn't allow_blank, so send null for empty
558564
// (covers internal apps with no author).
@@ -689,6 +695,26 @@ function SentryApplicationForm({
689695
)}
690696
</form.AppField>
691697

698+
<form.AppField name="webhookHeaders">
699+
{field => (
700+
<field.Layout.Row
701+
label={t('Webhook Headers')}
702+
hintText={t(
703+
'Custom headers to include with every webhook request. Enter one header per line in the format: Header-Name: value'
704+
)}
705+
>
706+
<field.TextArea
707+
autosize
708+
value={field.state.value}
709+
onChange={field.handleChange}
710+
placeholder={
711+
'anthropic-version: 2023-06-01\nauthorization: Bearer token123'
712+
}
713+
/>
714+
</field.Layout.Row>
715+
)}
716+
</form.AppField>
717+
692718
{!isInternal && (
693719
<form.AppField name="redirectUrl">
694720
{field => (

0 commit comments

Comments
 (0)